From c99000a2c387377eefb19ffe17923c73489315a9 Mon Sep 17 00:00:00 2001
From: David Capello <david@igarastudio.com>
Date: Sat, 22 May 2021 00:42:36 -0300
Subject: [PATCH] Add theme variants to switch easily between Light/Dark themes

---
 data/extensions/aseprite-theme/package.json |   8 +-
 data/widgets/options.xml                    |   2 +-
 src/app/commands/cmd_options.cpp            | 107 ++++++++++++++++++--
 src/app/extensions.cpp                      |  26 ++---
 src/app/extensions.h                        |  34 +++++--
 5 files changed, 143 insertions(+), 34 deletions(-)

diff --git a/data/extensions/aseprite-theme/package.json b/data/extensions/aseprite-theme/package.json
index e37e744fc..079bec673 100644
--- a/data/extensions/aseprite-theme/package.json
+++ b/data/extensions/aseprite-theme/package.json
@@ -1,9 +1,9 @@
 {
   "name": "aseprite-theme",
   "displayName": "Aseprite Default Theme",
-  "description": "Default Aseprite Pixel-Art Theme",
+  "description": "Default Aseprite Pixel-Art Themes",
   "version": "1.0",
-  "author": { "name": "David Capello", "email": "davidcapello@gmail.com", "url": "http://davidcapello.com/" },
+  "author": { "name": "David Capello", "email": "david@igarastudio.com", "url": "http://davidcapello.com/" },
   "contributors": [
     { "name": "Ilija Melentijevic", "url": "http://ilkke.blogspot.com/" },
     { "name": "Nicolas Desilets", "url": "https://twitter.com/MapleGecko" }
@@ -15,8 +15,8 @@
   ],
   "contributes": {
     "themes": [
-      { "id": "default", "path": "." },
-      { "id": "default-dark", "path": "./dark" }
+      { "id": "default", "path": ".", "variant": "Light" },
+      { "id": "default-dark", "path": "./dark", "variant": "Dark" }
     ]
   }
 }
diff --git a/data/widgets/options.xml b/data/widgets/options.xml
index 8a0c8f425..53b543193 100644
--- a/data/widgets/options.xml
+++ b/data/widgets/options.xml
@@ -39,7 +39,7 @@
               <listitem text="300%" value="3" />
               <listitem text="400%" value="4" />
             </combobox>
-	    <boxfiller />
+	    <hbox id="theme_variants" />
 
             <label text="@.ui_scaling" />
             <combobox id="ui_scale">
diff --git a/src/app/commands/cmd_options.cpp b/src/app/commands/cmd_options.cpp
index 622826863..c5d851d13 100644
--- a/src/app/commands/cmd_options.cpp
+++ b/src/app/commands/cmd_options.cpp
@@ -110,11 +110,13 @@ class OptionsWindow : public app::gen::Options {
 
   class ThemeItem : public ListItem {
   public:
-    ThemeItem(const std::string& path,
-              const std::string& name)
-      : ListItem(name.empty() ? "-- " + path + " --": name),
+    ThemeItem(const std::string& id,
+              const std::string& path,
+              const std::string& displayName = std::string(),
+              const std::string& variant = std::string())
+      : ListItem(createLabel(path, id, displayName, variant)),
         m_path(path),
-        m_name(name) {
+        m_name(id) {
     }
 
     const std::string& themePath() const { return m_path; }
@@ -129,6 +131,30 @@ class OptionsWindow : public app::gen::Options {
     }
 
   private:
+    static std::string createLabel(const std::string& path,
+                                   const std::string& id,
+                                   const std::string& displayName,
+                                   const std::string& variant) {
+      if (displayName.empty()) {
+        if (id.empty())
+          return fmt::format("-- {} --", path);
+        else
+          return id;
+      }
+      else if (id == displayName) {
+        if (variant.empty())
+          return id;
+        else
+          return fmt::format("{} - {}", id, variant);
+      }
+      else {
+        if (variant.empty())
+          return displayName;
+        else
+          return fmt::format("{} - {}", displayName, variant);
+      }
+    }
+
     std::string m_path;
     std::string m_name;
   };
@@ -190,6 +216,24 @@ class OptionsWindow : public app::gen::Options {
     Extension* m_extension;
   };
 
+  class ThemeVariantItem : public ButtonSet::Item {
+  public:
+    ThemeVariantItem(OptionsWindow* options,
+                     const std::string& id,
+                     const std::string& variant)
+      : m_options(options)
+      , m_themeId(id) {
+      setText(variant);
+    }
+  private:
+    void onClick() override {
+      m_options->setUITheme(m_themeId, true,
+                            false); // Don't recreate variants
+    }
+    OptionsWindow* m_options;
+    std::string m_themeId;
+  };
+
 public:
   OptionsWindow(Context* context, int& curSection)
     : m_context(context)
@@ -204,6 +248,9 @@ public:
   {
     sectionListbox()->Change.connect([this]{ onChangeSection(); });
 
+    // Theme variants
+    fillThemeVariants();
+
     // Default extension to save files
     fillExtensionsCombobox(defaultExtension(), m_pref.saveFile.defaultExtension());
     fillExtensionsCombobox(exportImageDefaultExtension(), m_pref.exportFile.imageDefaultExtension());
@@ -823,6 +870,42 @@ public:
 
 private:
 
+  void fillThemeVariants() {
+    ButtonSet* list = nullptr;
+    for (Extension* ext : App::instance()->extensions()) {
+      if (ext->isCurrentTheme()) {
+        // Number of variants
+        int c = 0;
+        for (auto it : ext->themes()) {
+          if (!it.second.variant.empty())
+            ++c;
+        }
+
+        if (c >= 2) {
+          list = new ButtonSet(c);
+          for (auto it : ext->themes()) {
+            if (!it.second.variant.empty()) {
+              auto item = list->addItem(
+                new ThemeVariantItem(this, it.first, it.second.variant));
+
+              if (it.first == Preferences::instance().theme.selected())
+                list->setSelectedItem(item, false);
+            }
+          }
+        }
+        break;
+      }
+    }
+    if (list) {
+      themeVariants()->addChild(list);
+    }
+    if (m_themeVars) {
+      themeVariants()->removeChild(m_themeVars);
+      m_themeVars->deferDelete();
+    }
+    m_themeVars = list;
+  }
+
   void fillExtensionsCombobox(ui::ComboBox* combobox,
                               const std::string& defExt) {
     base::paths exts = get_writable_extensions();
@@ -1196,7 +1279,7 @@ private:
             new SeparatorInView(base::normalize_path(path), HORIZONTAL));
         }
 
-        ThemeItem* item = new ThemeItem(fullPath, fn);
+        ThemeItem* item = new ThemeItem(fn, fullPath);
         themeList()->addChild(item);
 
         // Selected theme
@@ -1221,11 +1304,14 @@ private:
       }
 
       for (auto it : ext->themes()) {
-        ThemeItem* item = new ThemeItem(it.second, it.first);
+        ThemeItem* item = new ThemeItem(it.first,
+                                        it.second.path,
+                                        ext->displayName(),
+                                        it.second.variant);
         themeList()->addChild(item);
 
         // Selected theme
-        if (it.second == selectedPath)
+        if (it.second.path == selectedPath)
           themeList()->selectChild(item);
       }
     }
@@ -1296,7 +1382,8 @@ private:
   }
 
   void setUITheme(const std::string& themeName,
-                  const bool updateScaling) {
+                  const bool updateScaling,
+                  const bool recreateVariantsFields = true) {
     try {
       if (themeName != m_pref.theme.selected()) {
         auto theme = static_cast<skin::SkinTheme*>(ui::get_theme());
@@ -1343,6 +1430,9 @@ private:
             selectScalingItems();
           }
         }
+
+        if (recreateVariantsFields)
+          fillThemeVariants();
       }
     }
     catch (const std::exception& ex) {
@@ -1618,6 +1708,7 @@ private:
   std::vector<os::ColorSpaceRef> m_colorSpaces;
   std::string m_templateTextForDisplayCS;
   RgbMapAlgorithmSelector m_rgbmapAlgorithmSelector;
+  ButtonSet* m_themeVars = nullptr;
 };
 
 class OptionsCommand : public Command {
diff --git a/src/app/extensions.cpp b/src/app/extensions.cpp
index cde81cd99..84f451567 100644
--- a/src/app/extensions.cpp
+++ b/src/app/extensions.cpp
@@ -1,5 +1,5 @@
 // Aseprite
-// Copyright (C) 2020  Igara Studio S.A.
+// Copyright (C) 2020-2021  Igara Studio S.A.
 // Copyright (C) 2017-2018  David Capello
 //
 // This program is distributed under the terms of
@@ -263,9 +263,9 @@ void Extension::addLanguage(const std::string& id, const std::string& path)
   updateCategory(Category::Languages);
 }
 
-void Extension::addTheme(const std::string& id, const std::string& path)
+void Extension::addTheme(const std::string& id, const std::string& path, const std::string& variant)
 {
-  m_themes[id] = path;
+  m_themes[id] = ThemeInfo(path, variant);
   updateCategory(Category::Themes);
 }
 
@@ -789,7 +789,7 @@ std::string Extensions::themePath(const std::string& themeId)
 
     auto it = ext->themes().find(themeId);
     if (it != ext->themes().end())
-      return it->second;
+      return it->second.path;
   }
   return std::string();
 }
@@ -1026,7 +1026,7 @@ Extension* Extensions::loadExtension(const std::string& path,
         // The path must be always relative to the extension
         langPath = base::join_path(path, langPath);
 
-        LOG("EXT: New language '%s' in '%s'\n",
+        LOG("EXT: New language id=%s path=%s\n",
             langId.c_str(),
             langPath.c_str());
 
@@ -1040,15 +1040,17 @@ Extension* Extensions::loadExtension(const std::string& path,
       for (const auto& theme : themes.array_items()) {
         std::string themeId = theme["id"].string_value();
         std::string themePath = theme["path"].string_value();
+        std::string themeVariant = theme["variant"].string_value();
 
         // The path must be always relative to the extension
         themePath = base::join_path(path, themePath);
 
-        LOG("EXT: New theme '%s' in '%s'\n",
+        LOG("EXT: New theme id=%s path=%s variant=%s\n",
             themeId.c_str(),
-            themePath.c_str());
+            themePath.c_str(),
+            themeVariant.c_str());
 
-        extension->addTheme(themeId, themePath);
+        extension->addTheme(themeId, themePath, themeVariant);
       }
     }
 
@@ -1062,7 +1064,7 @@ Extension* Extensions::loadExtension(const std::string& path,
         // The path must be always relative to the extension
         palPath = base::join_path(path, palPath);
 
-        LOG("EXT: New palette '%s' in '%s'\n",
+        LOG("EXT: New palette id=%s path=%s\n",
             palId.c_str(),
             palPath.c_str());
 
@@ -1083,7 +1085,7 @@ Extension* Extensions::loadExtension(const std::string& path,
         // The path must be always relative to the extension
         matPath = base::join_path(path, matPath);
 
-        LOG("EXT: New dithering matrix '%s' in '%s'\n",
+        LOG("EXT: New dithering matrix id=%s path=%s\n",
             matId.c_str(),
             matPath.c_str());
 
@@ -1103,7 +1105,7 @@ Extension* Extensions::loadExtension(const std::string& path,
         // The path must be always relative to the extension
         scriptPath = base::join_path(path, scriptPath);
 
-        LOG("EXT: New script '%s'\n", scriptPath.c_str());
+        LOG("EXT: New script path=%s\n", scriptPath.c_str());
 
         extension->addScript(scriptPath);
       }
@@ -1116,7 +1118,7 @@ Extension* Extensions::loadExtension(const std::string& path,
       // The path must be always relative to the extension
       scriptPath = base::join_path(path, scriptPath);
 
-      LOG("EXT: New script '%s'\n", scriptPath.c_str());
+      LOG("EXT: New script path=%s\n", scriptPath.c_str());
 
       extension->addScript(scriptPath);
     }
diff --git a/src/app/extensions.h b/src/app/extensions.h
index ab2e7492b..8070e5551 100644
--- a/src/app/extensions.h
+++ b/src/app/extensions.h
@@ -1,5 +1,5 @@
 // Aseprite
-// Copyright (C) 2020  Igara Studio S.A.
+// Copyright (C) 2020-2021  Igara Studio S.A.
 // Copyright (C) 2017-2018  David Capello
 //
 // This program is distributed under the terms of
@@ -18,9 +18,9 @@
 
 namespace app {
 
-  // Key=theme/palette/etc. id
-  // Value=theme/palette/etc. path
-  typedef std::map<std::string, std::string> ExtensionItems;
+  // Key=id
+  // Value=path
+  using ExtensionItems = std::map<std::string, std::string>;
 
   class Extensions;
 
@@ -34,6 +34,7 @@ namespace app {
   class Extension {
     friend class Extensions;
   public:
+
     enum class Category {
       None,
       Languages,
@@ -61,6 +62,20 @@ namespace app {
       mutable bool m_loaded = false;
     };
 
+    struct ThemeInfo {
+      std::string path;
+      std::string variant;
+
+      ThemeInfo() = default;
+      ThemeInfo(const std::string& path,
+                const std::string& variant)
+        : path(path)
+        , variant(variant) { }
+    };
+
+    using Themes = std::map<std::string, ThemeInfo>;
+    using DitheringMatrices = std::map<std::string, DitheringMatrixInfo>;
+
     Extension(const std::string& path,
               const std::string& name,
               const std::string& version,
@@ -79,11 +94,11 @@ namespace app {
     const Category category() const { return m_category; }
 
     const ExtensionItems& languages() const { return m_languages; }
-    const ExtensionItems& themes() const { return m_themes; }
+    const Themes& themes() const { return m_themes; }
     const ExtensionItems& palettes() const { return m_palettes; }
 
     void addLanguage(const std::string& id, const std::string& path);
-    void addTheme(const std::string& id, const std::string& path);
+    void addTheme(const std::string& id, const std::string& path, const std::string& variant);
     void addPalette(const std::string& id, const std::string& path);
     void addDitheringMatrix(const std::string& id,
                             const std::string& path,
@@ -107,11 +122,12 @@ namespace app {
     void addScript(const std::string& fn);
 #endif
 
+    bool isCurrentTheme() const;
+
   private:
     void enable(const bool state);
     void uninstall();
     void uninstallFiles(const std::string& path);
-    bool isCurrentTheme() const;
     bool isDefaultTheme() const;
     void updateCategory(const Category newCategory);
 #ifdef ENABLE_SCRIPTING
@@ -120,9 +136,9 @@ namespace app {
 #endif
 
     ExtensionItems m_languages;
-    ExtensionItems m_themes;
+    Themes m_themes;
     ExtensionItems m_palettes;
-    std::map<std::string, DitheringMatrixInfo> m_ditheringMatrices;
+    DitheringMatrices m_ditheringMatrices;
 
 #ifdef ENABLE_SCRIPTING
     struct ScriptItem {