From cbb63bcd89804354f9c577b002870fb683f0c3c1 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 24 Apr 2024 09:43:05 +0800 Subject: [PATCH 1/5] fix --- routers/web/user/setting/profile.go | 8 +- services/webtheme/webtheme.go | 138 +++++++++++++++--- services/webtheme/webtheme_test.go | 20 +++ templates/user/settings/appearance.tmpl | 2 +- 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 + 9 files changed, 158 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 e5ff8570cf95..32735882e0db 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -319,13 +319,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() var hiddenCommentTypes *big.Int val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index dc801e1ff7ef..6d3d4f46775d 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,156 @@ 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 + PreferColorSchemes container.Set[string] +} + +func parseThemeMetaInfoToMap(cssContent string) map[string]string { + 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] + v = strings.TrimPrefix(v, "\"") + v = strings.TrimSuffix(v, "\"") + v = strings.ReplaceAll(v, `\"`, `"`) + m[item[2]] = v + } + return m +} + +// @media (prefers-color-scheme: dark) +func parseThemePreferColorSchemes(cssContent string) container.Set[string] { + re := regexp.MustCompile(`@media\s*\(\s*prefers-color-scheme\s*:\s*([-\w]+)\s*\)`) + matched := re.FindAllStringSubmatch(cssContent, -1) + if len(matched) == 0 { + return nil + } + schemes := container.Set[string]{} + for _, m := range matched { + schemes.Add(m[1]) + } + return schemes +} + +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) + themeInfo.PreferColorSchemes = parseThemePreferColorSchemes(cssContent) + 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 - } - name, ok = strings.CutSuffix(name, ".css") - 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))) } - 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 000000000000..11fcdf9e89a9 --- /dev/null +++ b/services/webtheme/webtheme_test.go @@ -0,0 +1,20 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package webtheme + +import ( + "testing" + + "code.gitea.io/gitea/modules/container" + + "github.com/stretchr/testify/assert" +) + +func TestParseThemeMetaInfo(t *testing.T) { + m := parseThemeMetaInfoToMap(`gitea-theme-meta-info { --k1: "v1"; --k2: "a\"b"; }`) + assert.Equal(t, map[string]string{"--k1": "v1", "--k2": `a"b`}, m) + + schemes := parseThemePreferColorSchemes(`@media (prefers-color-scheme: dark) {} @media (prefers-color-scheme: light) {} @media (prefers-color-scheme:dark) {}`) + assert.Equal(t, container.SetOf("dark", "light"), schemes) +} diff --git a/templates/user/settings/appearance.tmpl b/templates/user/settings/appearance.tmpl index 4fa248910a1f..362f73bcb802 100644 --- a/templates/user/settings/appearance.tmpl +++ b/templates/user/settings/appearance.tmpl @@ -18,7 +18,7 @@ diff --git a/web_src/css/themes/theme-gitea-auto.css b/web_src/css/themes/theme-gitea-auto.css index 509889e80225..cca49be99e8e 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 681aa3b5391c..c6efbeaa8e97 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 7bf2c982c6cb..18efced50567 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 7e03d90f5cc6..6d99c50a0376 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 dfccd37647d2..67bf0d5e65ce 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; From 0b9bfc9e1b721bff4115287471fb2f67f5ee7783 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 26 Apr 2024 00:58:39 +0800 Subject: [PATCH 2/5] remove PreferColorSchemes and add comments --- services/webtheme/webtheme.go | 28 +++++++++------------------- services/webtheme/webtheme_test.go | 5 ----- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 6d3d4f46775d..5e2245f7e4bc 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -28,13 +28,18 @@ const ( ) type ThemeMetaInfo struct { - FileName string - InternalName string - DisplayName string - PreferColorSchemes container.Set[string] + 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:] @@ -69,20 +74,6 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string { return m } -// @media (prefers-color-scheme: dark) -func parseThemePreferColorSchemes(cssContent string) container.Set[string] { - re := regexp.MustCompile(`@media\s*\(\s*prefers-color-scheme\s*:\s*([-\w]+)\s*\)`) - matched := re.FindAllStringSubmatch(cssContent, -1) - if len(matched) == 0 { - return nil - } - schemes := container.Set[string]{} - for _, m := range matched { - schemes.Add(m[1]) - } - return schemes -} - func defaultThemeMetaInfoByFileName(fileName string) *ThemeMetaInfo { themeInfo := &ThemeMetaInfo{ FileName: fileName, @@ -98,7 +89,6 @@ func defaultThemeMetaInfoByInternalName(fileName string) *ThemeMetaInfo { func parseThemeMetaInfo(fileName, cssContent string) *ThemeMetaInfo { themeInfo := defaultThemeMetaInfoByFileName(fileName) - themeInfo.PreferColorSchemes = parseThemePreferColorSchemes(cssContent) m := parseThemeMetaInfoToMap(cssContent) if m == nil { return themeInfo diff --git a/services/webtheme/webtheme_test.go b/services/webtheme/webtheme_test.go index 11fcdf9e89a9..42af39719dd7 100644 --- a/services/webtheme/webtheme_test.go +++ b/services/webtheme/webtheme_test.go @@ -6,15 +6,10 @@ package webtheme import ( "testing" - "code.gitea.io/gitea/modules/container" - "github.com/stretchr/testify/assert" ) func TestParseThemeMetaInfo(t *testing.T) { m := parseThemeMetaInfoToMap(`gitea-theme-meta-info { --k1: "v1"; --k2: "a\"b"; }`) assert.Equal(t, map[string]string{"--k1": "v1", "--k2": `a"b`}, m) - - schemes := parseThemePreferColorSchemes(`@media (prefers-color-scheme: dark) {} @media (prefers-color-scheme: light) {} @media (prefers-color-scheme:dark) {}`) - assert.Equal(t, container.SetOf("dark", "light"), schemes) } From d941875c17fa9a61c29c664955280e7d2da9908d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Fri, 3 May 2024 12:29:47 +0800 Subject: [PATCH 3/5] document --- .../administration/customizing-gitea.en-us.md | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/content/administration/customizing-gitea.en-us.md b/docs/content/administration/customizing-gitea.en-us.md index 8475f6d13143..f25f1242428f 100644 --- a/docs/content/administration/customizing-gitea.en-us.md +++ b/docs/content/administration/customizing-gitea.en-us.md @@ -383,13 +383,27 @@ To make a custom theme available to all users: The value of `$GITEA_CUSTOM` of your instance can be queried by calling `gitea help` and looking up the value of "CustomPath". 2. Add `` to the comma-separated list of setting `THEMES` in `app.ini`, or leave `THEMES` empty to allow all themes. +A custom theme file named `theme-my-theme.css` will be displayed as `my-theme` on the user's theme selection page. +It could add theme meta information into the custom theme CSS file to provide more information about the theme. + +If a custom theme is a dark theme, please set the global css variable `--is-dark-theme: true` in the `:root` block. +This allows Gitea to adjust the Monaco code editor's theme accordingly. + +```css +gitea-theme-meta-info { + --theme-display-name: "My Awesome Theme"; /* this theme will be display as "My Awesome Theme" on the UI */ +} +:root { + --is-dark-theme: true; /* if it is a dark theme */ + --color-primary: #112233; + /* more custom theme variables ... */ +} +``` + Community themes are listed in [gitea/awesome-gitea#themes](https://gitea.com/gitea/awesome-gitea#themes). The default theme sources can be found [here](https://github.com/go-gitea/gitea/blob/main/web_src/css/themes). -If your custom theme is considered a dark theme, set the global css variable `--is-dark-theme` to `true`. -This allows Gitea to adjust the Monaco code editor's theme accordingly. - ## Customizing fonts Fonts can be customized using CSS variables: From fdfd4400615afc19d4d42524aeac1f8dffa6ed2d Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 8 May 2024 09:36:47 +0800 Subject: [PATCH 4/5] update doc, add tests --- .../administration/customizing-gitea.en-us.md | 1 + services/webtheme/webtheme.go | 16 +++++++++--- services/webtheme/webtheme_test.go | 26 +++++++++++++++++-- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/content/administration/customizing-gitea.en-us.md b/docs/content/administration/customizing-gitea.en-us.md index f25f1242428f..e472fefbbf03 100644 --- a/docs/content/administration/customizing-gitea.en-us.md +++ b/docs/content/administration/customizing-gitea.en-us.md @@ -388,6 +388,7 @@ It could add theme meta information into the custom theme CSS file to provide mo If a custom theme is a dark theme, please set the global css variable `--is-dark-theme: true` in the `:root` block. This allows Gitea to adjust the Monaco code editor's theme accordingly. +An "auto" theme could be implemented by using "theme-gitea-auto.css" as a reference. ```css gitea-theme-meta-info { diff --git a/services/webtheme/webtheme.go b/services/webtheme/webtheme.go index 5e2245f7e4bc..58aea3bc74f6 100644 --- a/services/webtheme/webtheme.go +++ b/services/webtheme/webtheme.go @@ -49,7 +49,11 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string { ( \s*(--[-\w]+) \s*: -\s*("(\\"|[^"])*") +\s*( +("(\\"|[^"])*") +|('(\\'|[^'])*') +|([^'";]+) +) \s*; \s* ) @@ -66,9 +70,13 @@ func parseThemeMetaInfoToMap(cssContent string) map[string]string { m := map[string]string{} for _, item := range matchedItems { v := item[3] - v = strings.TrimPrefix(v, "\"") - v = strings.TrimSuffix(v, "\"") - v = strings.ReplaceAll(v, `\"`, `"`) + 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 diff --git a/services/webtheme/webtheme_test.go b/services/webtheme/webtheme_test.go index 42af39719dd7..5be2cb41655b 100644 --- a/services/webtheme/webtheme_test.go +++ b/services/webtheme/webtheme_test.go @@ -10,6 +10,28 @@ import ( ) func TestParseThemeMetaInfo(t *testing.T) { - m := parseThemeMetaInfoToMap(`gitea-theme-meta-info { --k1: "v1"; --k2: "a\"b"; }`) - assert.Equal(t, map[string]string{"--k1": "v1", "--k2": `a"b`}, m) + 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: dark) { gitea-theme-meta-info { --k1: bar; } } +gitea-theme-meta-info { + --k2: real; +}`) + assert.Equal(t, map[string]string{"--k2": "real"}, m) } From 40fd4b3c31811299830816a761b542c794ef497b Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Wed, 8 May 2024 09:50:07 +0800 Subject: [PATCH 5/5] fix typo --- services/webtheme/webtheme_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/webtheme/webtheme_test.go b/services/webtheme/webtheme_test.go index 5be2cb41655b..587953ab0c82 100644 --- a/services/webtheme/webtheme_test.go +++ b/services/webtheme/webtheme_test.go @@ -29,7 +29,7 @@ func TestParseThemeMetaInfo(t *testing.T) { // 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: dark) { gitea-theme-meta-info { --k1: bar; } } +@media (prefers-color-scheme: light) { gitea-theme-meta-info { --k1: bar; } } gitea-theme-meta-info { --k2: real; }`)