From da14b14b194ea4c066df81abacfcefb515c9d2b4 Mon Sep 17 00:00:00 2001 From: Kat21 <49850194+datkat21@users.noreply.github.com> Date: Thu, 26 Oct 2023 06:56:34 -0500 Subject: [PATCH] Add Theme Editor --- pkgs/kat/theme-editor/app.js | 753 ++++++++++++++++++++++++++ pkgs/kat/theme-editor/assets/icon.svg | 18 + pkgs/kat/theme-editor/meta.json5 | 30 + 3 files changed, 801 insertions(+) create mode 100644 pkgs/kat/theme-editor/app.js create mode 100644 pkgs/kat/theme-editor/assets/icon.svg create mode 100644 pkgs/kat/theme-editor/meta.json5 diff --git a/pkgs/kat/theme-editor/app.js b/pkgs/kat/theme-editor/app.js new file mode 100644 index 0000000..07f03d4 --- /dev/null +++ b/pkgs/kat/theme-editor/app.js @@ -0,0 +1,753 @@ +class CSSParser { + constructor() { + this.parse = function (cssText) { + const stylesheet = { rules: [] }; + const rules = cssText.match(/[^}]+}/g) || []; + + for (const ruleText of rules) { + const rule = this.parseRule(ruleText.trim()); + stylesheet.rules.push(rule); + } + + return stylesheet; + }; + + this.parseRule = function (ruleText) { + const parts = ruleText.split("{"); + const selectorText = parts[0].trim(); + const declarationsText = parts[1] && parts[1].trim(); // Check if parts[1] exists + const declarations = declarationsText + ? this.parseDeclarations(declarationsText) + : []; + + return { + selectorText, + declarations, + }; + }; + + this.parseDeclarations = function (declarationsText) { + const declarations = []; + const parts = declarationsText.split(";"); + + for (const declarationText of parts) { + if (declarationText.trim().length === 0) { + continue; + } + + const parts = declarationText.split(":"); + const property = parts[0].trim(); + const value = parts[1] && parts[1].trim(); // Check if parts[1] exists + + declarations.push({ + property, + value, + }); + } + + return declarations; + }; + } +} +async function parseCssVariables(theme) { + try { + const response = await fetch("style.css"); + const cssText = await response.text(); + + const parser = new CSSParser(); + const stylesheet = parser.parse(cssText); + + const rootRule = stylesheet.rules.find( + (rule) => rule.selectorText === `:root[data-theme="${theme}"]` + ); + + console.log("Searching for", theme, "in", stylesheet.rules); + + const cssVariables = {}; + + if (rootRule) { + for (const declaration of rootRule.declarations) { + if (declaration.property.startsWith("--")) { + cssVariables[declaration.property.substring(2)] = declaration.value; + } + } + } + + console.log(cssVariables); + + return cssVariables; + } catch (error) { + console.error("Error:", error); + } +} +const CACHE_DIR = "Root/Pluto/cache/lib"; +// To cache libraries on first app load +async function cacheLibrary(vfs, url, filename) { + let result = await vfs.exists(CACHE_DIR + "/" + filename); + let content; + + if (result === false) { + await vfs.createFolder(CACHE_DIR); + + content = await fetch(url).then((t) => t.text()); + + await vfs.writeFile(CACHE_DIR + "/" + filename, content); + } else { + content = await vfs.readFile(CACHE_DIR + "/" + filename); + } + + let lib = await import( + URL.createObjectURL(new Blob([content], { type: "application/javascript" })) + ); + + if (lib.default) lib = lib.default; + + console.log(filename, lib); + + return lib; +} + +export default { + name: "Theme Editor", + description: "Theme app", + ver: 1.4, // Compatible with core v1.4 + type: "process", + exec: async function (Root) { + const vfs = await Root.Lib.loadLibrary("VirtualFS"); + + await vfs.importFS(); + + console.log("Vfs", vfs); + + // To ensure some compatibility with 1.3 just in case + const Html = await cacheLibrary( + vfs, + "https://unpkg.com/@datkat21/html", + "html.js" + ); + + const Iro = await cacheLibrary( + vfs, + "https://unpkg.com/@jaames/iro@5.5.2/dist/iro.es.js", + "iro.es.js" + ); + + let wrapper, editWrapper; // Lib.html | undefined + let MyWindow, pickerWindow, editWindow; + + Root.Lib.setOnEnd((_) => { + MyWindow.close(); + pickerWindow && pickerWindow.close(); + editWindow && editWindow.close(); + }); + + const Win = (await Root.Lib.loadLibrary("WindowSystem")).win; + const FileDialog = await Root.Lib.loadLibrary("FileDialog"); + const Sidebar = await Root.Lib.loadComponent("Sidebar"); + + function colorPicker(color = "#ff0000") { + return new Promise((resolve, reject) => { + pickerWindow = new Win({ + title: "Pick a color", + width: 540, + height: 300, + resizable: false, + onclose: () => { + resolve(false); + return true; + }, + }); + + let wrapper2 = Html.from( + pickerWindow.window.querySelector(".win-content") + ); + + wrapper2.classOn("with-sidebar"); + + let pickerElement = new Html("div").class("color-picker"); + let textInput = new Html("input").attr({ + type: "text", + placeholder: "Hex color", + value: color, + }); + + let buttonRow = new Html("div").class("row", "gap").appendMany( + new Html("button") + .class("primary") + .text(Root.Lib.getString("ok")) + .on("click", (e) => { + pickerWindow.close(); + resolve({ + value: colorPicker.color.hslString, + rgb: colorPicker.color.rgbString.substring(4).replace(")", ""), + }); + }), + new Html("button") + .class("neutral") + .text(Root.Lib.getString("cancel")) + .on("click", (e) => { + pickerWindow.close(); + resolve(false); + }) + ); + + let vertWrapper = new Html("div") + .class("col", "gap", "container") + .appendMany(pickerElement, buttonRow); + let vertWrapper2 = new Html("div") + .class("col", "gap", "container") + .appendMany(textInput); + + let horizontalWrapper = new Html("div") + .class("row", "gap", "container") + .appendMany(vertWrapper, vertWrapper2); + + wrapper2.appendMany(horizontalWrapper); + + let colorPicker = Iro.ColorPicker(pickerElement.elm, { + // Set the size of the color picker + width: 144, + // Set the initial color to pure red + color, + }); + + colorPicker.on("color:change", (color) => { + textInput.val(color.hexString); + }); + textInput.on("input", (event) => { + colorPicker.color.hexString = textInput.getValue(); + }); + }); + } + async function promptValue(value = "", title = "Value to enter", parent) { + return await Root.Modal.input( + "Enter value", + title, + value, + parent, + false, + value + ); + } + + let colorsList = [ + "primary", + "negative", + "negative-light", + "negative-dark", + "positive", + "positive-light", + "positive-dark", + "warning", + "warning-light", + "warning-light-translucent", + "warning-dark", + "root", + "root-rgb", + "header", + "unfocused", + "text", + "text-rgb", + "text-alt", + "text-light", + "label", + "label-light", + "neutral", + "neutral-focus", + "outline", + ]; + let hideList = ["root-rgb", "text-rgb"]; + + function editColorsModal() { + editWindow = new Win({ + title: "Edit colors", + width: 540, + height: 360, + onclose: () => { + return true; + }, + }); + + editWrapper = Html.from(editWindow.window.querySelector(".win-content")); + + editWrapper.classOn("with-sidebar", "row-wrap", "gap"); + + function makeButtons() { + editWrapper.clear(); + let keys = Object.keys(currentDocument.data.values); + + for (let i = 0; i < keys.length; i++) { + if (hideList.includes(keys[i])) continue; + let value = currentDocument.data.values[keys[i]]; + + let isColor = colorsList.includes(keys[i]); + + if (isColor === true) { + new Html("button") + .class("row", "fc", "gap", "small") + .appendMany( + new Html("div").styleJs({ + width: "24px", + height: "24px", + borderRadius: "4px", + border: "1px solid var(--outline)", + backgroundColor: value, + }), + new Html("div").text(keys[i]) + ) + .appendTo(editWrapper) + .on("click", async (e) => { + await customizeColor(keys[i]); + makeButtons(); + }); + } else { + new Html("button") + .class("row", "fc", "gap") + .appendMany(new Html("div").text(keys[i])) + .appendTo(editWrapper) + .on("click", async (e) => { + await customizeValue(keys[i], editWrapper.elm); + makeButtons(); + }); + } + } + } + + makeButtons(); + } + function editMetaModal() { + editWindow = new Win({ + title: "Edit metadata", + width: 540, + height: 115, + onclose: () => { + return true; + }, + }); + + editWrapper = Html.from(editWindow.window.querySelector(".win-content")); + + editWrapper.classOn("with-sidebar", "row-wrap", "gap"); + + function makeButtons() { + let data = { + version: currentDocument.data.version, + name: currentDocument.data.name, + description: currentDocument.data.description, + cssThemeDataset: currentDocument.data.cssThemeDataset, + wallpaper: currentDocument.data.wallpaper, + }; + + editWrapper.clear(); + let keys = Object.keys(data); + + for (let i = 0; i < keys.length; i++) { + if (hideList.includes(keys[i])) continue; + let value = data[keys[i]]; + + new Html("button") + .appendMany(new Html("div").text(keys[i])) + .appendTo(editWrapper) + .on("click", async (e) => { + let result = await promptValue(value, keys[i], editWrapper.elm); + if (result !== false) { + currentDocument.dirty = true; + updateTitle(); + if (keys[i] === "name") { + themeName.text(result); + currentDocument.data[keys[i]] = result; + } else if (keys[i] === "version") { + currentDocument.data["version"] = parseFloat(result); + } else currentDocument.data[keys[i]] = result; + } + makeButtons(); + }); + } + } + + makeButtons(); + } + + // Create a window + MyWindow = new Win({ + title: "Theme Editor", + onclose: async () => { + if (currentDocument.dirty === true) { + let result = await Root.Modal.prompt( + "Warning", + "You have unsaved changes, are you sure you want to exit?", + NpWindow.window + ); + if (result !== true) { + return false; + } + } + Root.Lib.onEnd(); + }, + width: 540, + height: 360, + }); + + wrapper = Html.from(MyWindow.window.querySelector(".win-content")); + wrapper.classOn("row", "o-h", "h-100", "with-sidebar"); + + function setColor(colorName, value, colorRgb = undefined) { + wrapper.style({ + [`--${colorName}`]: value, + }); + if (colorName === "text") { + wrapper.style({ color: "var(--text)" }); + } else if (colorName === "primary") { + wrapper.style({ "accent-color": "var(--primary)" }); + } + currentDocument.data.values[colorName] = value; + if ( + (colorName === "root" || colorName === "text") && + colorRgb !== undefined + ) { + currentDocument.data.values[colorName + "-rgb"] = colorRgb; + wrapper.style({ + [`--${colorName}-rgb`]: colorRgb, + }); + } + } + + const defaultTheme = { + version: 1, + name: "My Theme", + description: "A custom theme.", + values: { + primary: "hsl(222, 80%, 40%)", + negative: "hsl(0, 80%, 40%)", + "negative-light": "hsl(0, 80%, 73%)", + "negative-dark": "hsl(0, 79%, 25%)", + positive: "hsl(133, 80%, 40%)", + "positive-light": "hsl(134, 81%, 72%)", + "positive-dark": "hsl(133, 79%, 25%)", + warning: "hsla(60, 90%, 40%, 0.8)", + "warning-light": "rgba(247, 247, 120, 1)", + "warning-light-translucent": "rgba(247, 247, 120, 0.4)", + "warning-dark": "hsla(60, 96%, 25%, 0.2)", + root: "hsl(222, 25%, 8%)", + "root-rgb": "15, 18, 25", + header: "hsl(219, 28%, 12%)", + unfocused: "hsl(218, 31%, 5%)", + text: "hsl(0, 0%, 100%)", + "text-rgb": "255, 255, 255", + "text-alt": "hsl(222, 33%, 80%)", + "text-light": "hsl(0, 0%, 100%)", + label: "hsl(222, 16%, 38%)", + "label-light": "hsl(222, 15%, 50%)", + neutral: "hsl(222, 26%, 18%)", + "neutral-focus": "hsl(222, 27%, 20%)", + outline: "hsl(222, 23%, 22%)", + "easing-function": "cubic-bezier(0.65, 0, 0.35, 1)", + "short-animation-duration": "0.15s", + "animation-duration": "0.45s", + "long-animation-duration": "1s", + }, + cssThemeDataset: null, + wallpaper: "https://pluto.zeon.dev/assets/wallpapers/space.png", + }; + + let currentDocument = { + path: "", + dirty: false, + data: defaultTheme, + }; + + const updateTitle = (_) => { + MyWindow.window.querySelector(".win-titlebar .title").innerText = `${ + currentDocument.dirty === true ? "\u2022" : "" + } Theme Editor - ${ + currentDocument.path === "" + ? "Untitled" + : currentDocument.path.split("/").pop() + }`.trim(); + themeName.text(currentDocument.data.name); + }; + + async function newDocument(path, content) { + currentDocument.path = path; + currentDocument.dirty = false; + if (content === "") { + currentDocument.data = defaultTheme; + } else { + try { + currentDocument.data = JSON.parse(content); + } catch (e) { + Root.Modal.alert("Alert", "Could not read the file!"); + } + } + updateTitle(); + let obj; + if (currentDocument.data.values) { + obj = currentDocument.data.values; + } else if ( + currentDocument.data.cssThemeDataset !== undefined && + currentDocument.data.cssThemeDataset !== null + ) { + // Parse the CSS theme + obj = await parseCssVariables(currentDocument.data.cssThemeDataset); + currentDocument.data.values = obj; + } + + let keys = Object.keys(obj); + for (let i = 0; i < keys.length; i++) { + setColor(keys[i], obj[keys[i]]); + } + } + + async function openFile() { + let file = await FileDialog.pickFile( + (await vfs.getParentFolder(currentDocument.path)) || + "Root/Pluto/config/themes" + ); + if (file === false) return; + let content = await vfs.readFile(file); + newDocument(file, content); + MyWindow.focus(); + } + async function saveFile() { + // make sure the path is not unreasonable + if (currentDocument.path === "") { + return saveAs(); + } + await vfs.writeFile( + currentDocument.path, + JSON.stringify(currentDocument.data) + ); + currentDocument.dirty = false; + updateTitle(); + } + async function saveAs() { + let result = await FileDialog.saveFile( + (await vfs.getParentFolder(currentDocument.path)) || + "Root/Pluto/config/themes" + ); + if (result === false) return false; + + await vfs.writeFile(result, JSON.stringify(currentDocument.data)); + + currentDocument.dirty = false; + currentDocument.path = result; + updateTitle(); + } + + async function dirtyCheck() { + if (currentDocument.dirty === true) { + let result = await Root.Modal.prompt( + "Warning", + "You have unsaved changes, are you sure you want to proceed?", + MyWindow.window + ); + if (result !== true) { + return false; + } + } + return true; + } + + async function customizeColor(type) { + let color = await colorPicker(currentDocument.data.values[type], type); + + console.log("Got", color); + + if (color !== false) setColor(type, color.value, color.rgb); + else { + currentDocument.dirty = true; + updateTitle(); + } + } + async function customizeValue(type, parent) { + let color = await promptValue( + currentDocument.data.values[type], + type, + parent + ); + + if (color !== false) setColor(type, color); + else { + currentDocument.dirty = true; + updateTitle(); + } + } + + Sidebar.new(wrapper.elm, [ + { + onclick: async (_) => { + const result = await dirtyCheck(); + if (result === false) return; + newDocument("", ""); + }, + title: "New", + html: Root.Lib.icons.newFile, + }, + { + onclick: async (_) => { + const result = await dirtyCheck(); + if (result === false) return; + openFile(); + }, + title: "Open...", + html: Root.Lib.icons.openFolder, + }, + { + onclick: async (_) => { + await saveFile(); + }, + title: "Save", + html: Root.Lib.icons.save, + }, + { + onclick: async (_) => { + await saveAs(); + }, + title: "Save as...", + html: Root.Lib.icons.saveAll, + }, + { + style: { + "margin-top": "auto", + }, + onclick: async (_) => { + let result = await Root.Modal.prompt( + "Help", + "Would you like to learn how to use this program?" + ); + + if (result === true) { + await Root.Modal.alert( + "How to use", + "On the left, the sidebar acts as a way to manage the current open document. You can use the 'open' button to use an existing theme as a base, or open one of your own custom themes." + ); + await Root.Modal.alert( + "How to use", + "In the middle, the window preview appears, showing how your theme will look." + ); + await Root.Modal.alert( + "How to use", + "On the right section, you can find two buttons to open windows related to modifying color data and metadata of your theme." + ); + } + }, + html: Root.Lib.icons.help, + }, + ]); + + new Html("div").appendTo(wrapper).appendMany( + new Html("div") + .styleJs({ position: "relative", width: "296px" }) + .appendMany( + new Html("div") + .class("win-window-decorative") + .styleJs({ + top: "15px", + left: "15px", + width: "240px", + height: "180px", + }) + .appendMany( + new Html("div") + .class("win-titlebar") + .appendMany( + new Html("div") + .class("buttons") + .appendMany( + new Html("button").class("win-btn", "win-minimize") + ), + new Html("div") + .class("outer-title") + .appendMany(new Html("div").class("title").text("Preview")), + new Html("div") + .class("buttons") + .appendMany( + new Html("button").class("win-btn", "win-close") + ) + ) + ), + new Html("div") + .class("win-window-decorative", "focus") + .styleJs({ + top: "40px", + left: "40px", + width: "240px", + }) + .appendMany( + new Html("div") + .class("win-titlebar") + .appendMany( + new Html("div") + .class("buttons") + .appendMany( + new Html("button").class("win-btn", "win-minimize") + ), + new Html("div") + .class("outer-title") + .appendMany(new Html("div").class("title").text("Preview")), + new Html("div") + .class("buttons") + .appendMany( + new Html("button").class("win-btn", "win-close") + ) + ), + new Html("div") + .class("win-content", "col", "gap") + .appendMany( + new Html("div") + .class("row-wrap", "gap") + .appendMany( + new Html("button") + .class("mc", "mhc", "m-0") + .text("Button"), + new Html("button") + .class("primary", "mc", "m-0") + .text("Primary"), + new Html("button") + .class("danger", "mc", "m-0") + .text("Danger"), + new Html("button") + .class("success", "mc", "m-0") + .text("Success") + ), + new Html("div") + .class("col", "gap") + .appendMany( + new Html("input") + .class("mc") + .attr({ placeholder: "Input text" }) + ), + new Html("div") + .class("col", "gap") + .html( + `Select` + ) + ) + ) + ) + ); + + let themeName = new Html("span").text("Theme Name"); + + new Html("div") + .class("col", "fg", "container", "gap") + .appendTo(wrapper) + .appendMany( + themeName, + new Html("button") + .class("small", "mc") + .text("Edit Colors") + .on("click", editColorsModal), + new Html("button") + .class("small", "mc") + .text("Edit Metadata") + .on("click", editMetaModal) + ); + + newDocument("", ""); + + return Root.Lib.setupReturns((m) => { + console.log("Example received message: " + m); + }); + }, +}; diff --git a/pkgs/kat/theme-editor/assets/icon.svg b/pkgs/kat/theme-editor/assets/icon.svg new file mode 100644 index 0000000..c116090 --- /dev/null +++ b/pkgs/kat/theme-editor/assets/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pkgs/kat/theme-editor/meta.json5 b/pkgs/kat/theme-editor/meta.json5 new file mode 100644 index 0000000..56addf3 --- /dev/null +++ b/pkgs/kat/theme-editor/meta.json5 @@ -0,0 +1,30 @@ +// An example metadata file for an application +{ + // Name for your application + name: "Theme Editor", + // A simple description for your application + shortDescription: "A simple app to make your own themes", + // Description for your application + description: "A simple app to make your own Pluto themes.", + // Who created this app? Should be the same as the folder name, or a localized string version. + author: "Kat21", + // The minimum core version your app is compatible with. + // 1.2 brought some big changes so it should be targeted as the default + compatibleWith: 1.4, + // App assets (shown in the app store) + assets: { + path: "app.js", + icon: "assets/icon.svg", + banner: null, + }, + // Example section for localization + // (not currently implemented but planned to be supported) + // !! You can remove this section if you don't have any localized metadata. !! + localization: { + // English (United States) + en_US: { + name: "Theme Editor", + description: "A simple app to make your own Pluto themes.", + }, + }, +}