From 6f1333175461a6cf5497965c20b5d81b6e73c5d5 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Sun, 9 Mar 2025 05:38:11 +0800
Subject: [PATCH] Improve theme display (#30671)

Document: https://gitea.com/gitea/docs/pulls/180

![image](https://github.com/go-gitea/gitea/assets/2114189/68e38573-b911-45d9-b7aa-40d96d836ecb)
---
 routers/web/user/setting/profile.go           |   8 +-
 services/webtheme/webtheme.go                 | 136 +++++++++++++++---
 services/webtheme/webtheme_test.go            |  37 +++++
 templates/user/settings/appearance.tmpl       |   2 +-
 ...eme-gitea-auto-protanopia-deuteranopia.css |   4 +
 web_src/css/themes/theme-gitea-auto.css       |   4 +
 ...eme-gitea-dark-protanopia-deuteranopia.css |   4 +
 web_src/css/themes/theme-gitea-dark.css       |   4 +
 ...me-gitea-light-protanopia-deuteranopia.css |   4 +
 web_src/css/themes/theme-gitea-light.css      |   4 +
 10 files changed, 177 insertions(+), 30 deletions(-)
 create mode 100644 services/webtheme/webtheme_test.go

diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
index ebf682bf58..7577036a55 100644
--- a/routers/web/user/setting/profile.go
+++ b/routers/web/user/setting/profile.go
@@ -338,13 +338,7 @@ func Repos(ctx *context.Context) {
 func Appearance(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("settings.appearance")
 	ctx.Data["PageIsSettingsAppearance"] = true
-
-	allThemes := webtheme.GetAvailableThemes()
-	if webtheme.IsThemeAvailable(setting.UI.DefaultTheme) {
-		allThemes = util.SliceRemoveAll(allThemes, setting.UI.DefaultTheme)
-		allThemes = append([]string{setting.UI.DefaultTheme}, allThemes...) // move the default theme to the top
-	}
-	ctx.Data["AllThemes"] = allThemes
+	ctx.Data["AllThemes"] = webtheme.GetAvailableThemes()
 	ctx.Data["UserDisabledFeatures"] = user_model.DisabledFeaturesWithLoginType(ctx.Doer)
 
 	var hiddenCommentTypes *big.Int
diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go
index dc801e1ff7..58aea3bc74 100644
--- a/services/webtheme/webtheme.go
+++ b/services/webtheme/webtheme.go
@@ -4,6 +4,7 @@
 package webtheme
 
 import (
+	"regexp"
 	"sort"
 	"strings"
 	"sync"
@@ -12,63 +13,154 @@ import (
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/public"
 	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/util"
 )
 
 var (
-	availableThemes    []string
-	availableThemesSet container.Set[string]
-	themeOnce          sync.Once
+	availableThemes             []*ThemeMetaInfo
+	availableThemeInternalNames container.Set[string]
+	themeOnce                   sync.Once
 )
 
+const (
+	fileNamePrefix = "theme-"
+	fileNameSuffix = ".css"
+)
+
+type ThemeMetaInfo struct {
+	FileName     string
+	InternalName string
+	DisplayName  string
+}
+
+func parseThemeMetaInfoToMap(cssContent string) map[string]string {
+	/*
+		The theme meta info is stored in the CSS file's variables of `gitea-theme-meta-info` element,
+		which is a privately defined and is only used by backend to extract the meta info.
+		Not using ":root" because it is difficult to parse various ":root" blocks when importing other files,
+		it is difficult to control the overriding, and it's difficult to avoid user's customized overridden styles.
+	*/
+	metaInfoContent := cssContent
+	if pos := strings.LastIndex(metaInfoContent, "gitea-theme-meta-info"); pos >= 0 {
+		metaInfoContent = metaInfoContent[pos:]
+	}
+
+	reMetaInfoItem := `
+(
+\s*(--[-\w]+)
+\s*:
+\s*(
+("(\\"|[^"])*")
+|('(\\'|[^'])*')
+|([^'";]+)
+)
+\s*;
+\s*
+)
+`
+	reMetaInfoItem = strings.ReplaceAll(reMetaInfoItem, "\n", "")
+	reMetaInfoBlock := `\bgitea-theme-meta-info\s*\{(` + reMetaInfoItem + `+)\}`
+	re := regexp.MustCompile(reMetaInfoBlock)
+	matchedMetaInfoBlock := re.FindAllStringSubmatch(metaInfoContent, -1)
+	if len(matchedMetaInfoBlock) == 0 {
+		return nil
+	}
+	re = regexp.MustCompile(strings.ReplaceAll(reMetaInfoItem, "\n", ""))
+	matchedItems := re.FindAllStringSubmatch(matchedMetaInfoBlock[0][1], -1)
+	m := map[string]string{}
+	for _, item := range matchedItems {
+		v := item[3]
+		if strings.HasPrefix(v, `"`) {
+			v = strings.TrimSuffix(strings.TrimPrefix(v, `"`), `"`)
+			v = strings.ReplaceAll(v, `\"`, `"`)
+		} else if strings.HasPrefix(v, `'`) {
+			v = strings.TrimSuffix(strings.TrimPrefix(v, `'`), `'`)
+			v = strings.ReplaceAll(v, `\'`, `'`)
+		}
+		m[item[2]] = v
+	}
+	return m
+}
+
+func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo {
+	themeInfo := &ThemeMetaInfo{
+		FileName:     fileName,
+		InternalName: strings.TrimSuffix(strings.TrimPrefix(fileName, fileNamePrefix), fileNameSuffix),
+	}
+	themeInfo.DisplayName = themeInfo.InternalName
+	return themeInfo
+}
+
+func defaultThemeMetaInfoByInternalName(fileName string) *ThemeMetaInfo {
+	return defaultThemeMetaInfoByFileName(fileNamePrefix + fileName + fileNameSuffix)
+}
+
+func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo {
+	themeInfo := defaultThemeMetaInfoByFileName(fileName)
+	m := parseThemeMetaInfoToMap(cssContent)
+	if m == nil {
+		return themeInfo
+	}
+	themeInfo.DisplayName = m["--theme-display-name"]
+	return themeInfo
+}
+
 func initThemes() {
 	availableThemes = nil
 	defer func() {
-		availableThemesSet = container.SetOf(availableThemes...)
-		if !availableThemesSet.Contains(setting.UI.DefaultTheme) {
+		availableThemeInternalNames = container.Set[string]{}
+		for _, theme := range availableThemes {
+			availableThemeInternalNames.Add(theme.InternalName)
+		}
+		if !availableThemeInternalNames.Contains(setting.UI.DefaultTheme) {
 			setting.LogStartupProblem(1, log.ERROR, "Default theme %q is not available, please correct the '[ui].DEFAULT_THEME' setting in the config file", setting.UI.DefaultTheme)
 		}
 	}()
 	cssFiles, err := public.AssetFS().ListFiles("/assets/css")
 	if err != nil {
 		log.Error("Failed to list themes: %v", err)
-		availableThemes = []string{setting.UI.DefaultTheme}
+		availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
 		return
 	}
-	var foundThemes []string
-	for _, name := range cssFiles {
-		name, ok := strings.CutPrefix(name, "theme-")
-		if !ok {
-			continue
+	var foundThemes []*ThemeMetaInfo
+	for _, fileName := range cssFiles {
+		if strings.HasPrefix(fileName, fileNamePrefix) && strings.HasSuffix(fileName, fileNameSuffix) {
+			content, err := public.AssetFS().ReadFile("/assets/css/" + fileName)
+			if err != nil {
+				log.Error("Failed to read theme file %q: %v", fileName, err)
+				continue
+			}
+			foundThemes = append(foundThemes, parseThemeMetaInfo(fileName, util.UnsafeBytesToString(content)))
 		}
-		name, ok = strings.CutSuffix(name, ".css")
-		if !ok {
-			continue
-		}
-		foundThemes = append(foundThemes, name)
 	}
 	if len(setting.UI.Themes) > 0 {
 		allowedThemes := container.SetOf(setting.UI.Themes...)
 		for _, theme := range foundThemes {
-			if allowedThemes.Contains(theme) {
+			if allowedThemes.Contains(theme.InternalName) {
 				availableThemes = append(availableThemes, theme)
 			}
 		}
 	} else {
 		availableThemes = foundThemes
 	}
-	sort.Strings(availableThemes)
+	sort.Slice(availableThemes, func(i, j int) bool {
+		if availableThemes[i].InternalName == setting.UI.DefaultTheme {
+			return true
+		}
+		return availableThemes[i].DisplayName < availableThemes[j].DisplayName
+	})
 	if len(availableThemes) == 0 {
 		setting.LogStartupProblem(1, log.ERROR, "No theme candidate in asset files, but Gitea requires there should be at least one usable theme")
-		availableThemes = []string{setting.UI.DefaultTheme}
+		availableThemes = []*ThemeMetaInfo{defaultThemeMetaInfoByInternalName(setting.UI.DefaultTheme)}
 	}
 }
 
-func GetAvailableThemes() []string {
+func GetAvailableThemes() []*ThemeMetaInfo {
 	themeOnce.Do(initThemes)
 	return availableThemes
 }
 
-func IsThemeAvailable(name string) bool {
+func IsThemeAvailable(internalName string) bool {
 	themeOnce.Do(initThemes)
-	return availableThemesSet.Contains(name)
+	return availableThemeInternalNames.Contains(internalName)
 }
diff --git a/services/webtheme/webtheme_test.go b/services/webtheme/webtheme_test.go
new file mode 100644
index 0000000000..587953ab0c
--- /dev/null
+++ b/services/webtheme/webtheme_test.go
@@ -0,0 +1,37 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package webtheme
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestParseThemeMetaInfo(t *testing.T) {
+	m := parseThemeMetaInfoToMap(`gitea-theme-meta-info {
+	--k1: "v1";
+	--k2: "v\"2";
+	--k3: 'v3';
+	--k4: 'v\'4';
+	--k5: v5;
+}`)
+	assert.Equal(t, map[string]string{
+		"--k1": "v1",
+		"--k2": `v"2`,
+		"--k3": "v3",
+		"--k4": "v'4",
+		"--k5": "v5",
+	}, m)
+
+	// if an auto theme imports others, the meta info should be extracted from the last one
+	// the meta in imported themes should be ignored to avoid incorrect overriding
+	m = parseThemeMetaInfoToMap(`
+@media (prefers-color-scheme: dark) { gitea-theme-meta-info { --k1: foo; } }
+@media (prefers-color-scheme: light) { gitea-theme-meta-info { --k1: bar; } }
+gitea-theme-meta-info {
+	--k2: real;
+}`)
+	assert.Equal(t, map[string]string{"--k2": "real"}, m)
+}
diff --git a/templates/user/settings/appearance.tmpl b/templates/user/settings/appearance.tmpl
index 4fa248910a..362f73bcb8 100644
--- a/templates/user/settings/appearance.tmpl
+++ b/templates/user/settings/appearance.tmpl
@@ -18,7 +18,7 @@
 					<label>{{ctx.Locale.Tr "settings.ui"}}</label>
 					<select name="theme" class="ui dropdown">
 						{{range $theme := .AllThemes}}
-						<option value="{{$theme}}" {{Iif (eq $.SignedUser.Theme $theme) "selected"}}>{{$theme}}</option>
+						<option value="{{$theme.InternalName}}" {{Iif (eq $.SignedUser.Theme $theme.InternalName) "selected"}}>{{$theme.DisplayName}}</option>
 						{{end}}
 					</select>
 				</div>
diff --git a/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css b/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css
index bcbf67d13d..418d7daeab 100644
--- a/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css
+++ b/web_src/css/themes/theme-gitea-auto-protanopia-deuteranopia.css
@@ -1,2 +1,6 @@
 @import "./theme-gitea-light-protanopia-deuteranopia.css" (prefers-color-scheme: light);
 @import "./theme-gitea-dark-protanopia-deuteranopia.css" (prefers-color-scheme: dark);
+
+gitea-theme-meta-info {
+  --theme-display-name: "Auto (Red/Green Colorblind-friendly)";
+}
diff --git a/web_src/css/themes/theme-gitea-auto.css b/web_src/css/themes/theme-gitea-auto.css
index 509889e802..cca49be99e 100644
--- a/web_src/css/themes/theme-gitea-auto.css
+++ b/web_src/css/themes/theme-gitea-auto.css
@@ -1,2 +1,6 @@
 @import "./theme-gitea-light.css" (prefers-color-scheme: light);
 @import "./theme-gitea-dark.css" (prefers-color-scheme: dark);
+
+gitea-theme-meta-info {
+  --theme-display-name: "Auto";
+}
diff --git a/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css b/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css
index c1a6edaf35..928cb8ba19 100644
--- a/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css
+++ b/web_src/css/themes/theme-gitea-dark-protanopia-deuteranopia.css
@@ -1,5 +1,9 @@
 @import "./theme-gitea-dark.css";
 
+gitea-theme-meta-info {
+  --theme-display-name: "Dark (Red/Green Colorblind-friendly)";
+}
+
 /* red/green colorblind-friendly colors */
 /* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
 :root {
diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css
index 9bc7747697..5ddee0a746 100644
--- a/web_src/css/themes/theme-gitea-dark.css
+++ b/web_src/css/themes/theme-gitea-dark.css
@@ -1,6 +1,10 @@
 @import "../chroma/dark.css";
 @import "../codemirror/dark.css";
 
+gitea-theme-meta-info {
+  --theme-display-name: "Dark";
+}
+
 :root {
   --is-dark-theme: true;
   --color-primary: #4183c4;
diff --git a/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css b/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css
index f42fa1db2c..32d920582c 100644
--- a/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css
+++ b/web_src/css/themes/theme-gitea-light-protanopia-deuteranopia.css
@@ -1,5 +1,9 @@
 @import "./theme-gitea-light.css";
 
+gitea-theme-meta-info {
+  --theme-display-name: "Light (Red/Green Colorblind-friendly)";
+}
+
 /* red/green colorblind-friendly colors */
 /* from GitHub: --diffBlob-addition-*, --diffBlob-deletion-*, etc */
 :root {
diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css
index d7f9debf90..1a4183c0d2 100644
--- a/web_src/css/themes/theme-gitea-light.css
+++ b/web_src/css/themes/theme-gitea-light.css
@@ -1,6 +1,10 @@
 @import "../chroma/light.css";
 @import "../codemirror/light.css";
 
+gitea-theme-meta-info {
+  --theme-display-name: "Light";
+}
+
 :root {
   --is-dark-theme: false;
   --color-primary: #4183c4;