Update action status badge layout (#34018)

The current action status badge are looking different from most other
badges renders, which is especially noticeable when using them along
with other badges. This PR updates the action badges to match the
commonly used badges from other providers.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
pull/33911/head
bytedream 2025-03-28 16:12:47 +01:00 committed by GitHub
parent 0d2607a303
commit bf9500b3f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 413 additions and 136 deletions

View File

@ -4,6 +4,9 @@
package badge
import (
"strings"
"unicode"
actions_model "code.gitea.io/gitea/models/actions"
)
@ -11,54 +14,35 @@ import (
// We use 10x scale to calculate more precisely
// Then scale down to normal size in tmpl file
type Label struct {
text string
width int
}
func (l Label) Text() string {
return l.text
}
func (l Label) Width() int {
return l.width
}
func (l Label) TextLength() int {
return int(float64(l.width-defaultOffset) * 9.5)
}
func (l Label) X() int {
return l.width*5 + 10
}
type Message struct {
type Text struct {
text string
width int
x int
}
func (m Message) Text() string {
return m.text
func (t Text) Text() string {
return t.text
}
func (m Message) Width() int {
return m.width
func (t Text) Width() int {
return t.width
}
func (m Message) X() int {
return m.x
func (t Text) X() int {
return t.x
}
func (m Message) TextLength() int {
return int(float64(m.width-defaultOffset) * 9.5)
func (t Text) TextLength() int {
return int(float64(t.width-defaultOffset) * 10)
}
type Badge struct {
Color string
FontSize int
Label Label
Message Message
IDPrefix string
FontFamily string
Color string
FontSize int
Label Text
Message Text
}
func (b Badge) Width() int {
@ -66,10 +50,10 @@ func (b Badge) Width() int {
}
const (
defaultOffset = 9
defaultFontSize = 11
DefaultColor = "#9f9f9f" // Grey
defaultFontWidth = 7 // approximate speculation
defaultOffset = 10
defaultFontSize = 11
DefaultColor = "#9f9f9f" // Grey
DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif"
)
var StatusColorMap = map[actions_model.Status]string{
@ -85,20 +69,43 @@ var StatusColorMap = map[actions_model.Status]string{
// GenerateBadge generates badge with given template
func GenerateBadge(label, message, color string) Badge {
lw := defaultFontWidth*len(label) + defaultOffset
mw := defaultFontWidth*len(message) + defaultOffset
x := lw*10 + mw*5 - 10
lw := calculateTextWidth(label) + defaultOffset
mw := calculateTextWidth(message) + defaultOffset
lx := lw * 5
mx := lw*10 + mw*5 - 10
return Badge{
Label: Label{
FontFamily: DefaultFontFamily,
Label: Text{
text: label,
width: lw,
x: lx,
},
Message: Message{
Message: Text{
text: message,
width: mw,
x: x,
x: mx,
},
FontSize: defaultFontSize * 10,
Color: color,
}
}
func calculateTextWidth(text string) int {
width := 0
widthData := DejaVuGlyphWidthData()
for _, char := range strings.TrimSpace(text) {
charWidth, ok := widthData[char]
if !ok {
// use the width of 'm' in case of missing glyph width data for a printable character
if unicode.IsPrint(char) {
charWidth = widthData['m']
} else {
charWidth = 0
}
}
width += int(charWidth)
}
return width
}

View File

@ -0,0 +1,208 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package badge
import "sync"
// DejaVuGlyphWidthData is generated by `sfnt.Face.GlyphAdvance(nil, <rune>, 11, font.HintingNone)` with DejaVu Sans
// v2.37 (https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-sans-ttf-2.37.zip).
//
// Fonts defined in "DefaultFontFamily" all have similar widths (including "DejaVu Sans"),
// and these widths are fixed and don't seem to change.
//
// A devtest page "/devtest/badge-actions-svg" could be used to check the rendered images.
var DejaVuGlyphWidthData = sync.OnceValue(func() map[rune]uint8 {
return map[rune]uint8{
32: 3,
33: 4,
34: 5,
35: 9,
36: 7,
37: 10,
38: 9,
39: 3,
40: 4,
41: 4,
42: 6,
43: 9,
44: 3,
45: 4,
46: 3,
47: 4,
48: 7,
49: 7,
50: 7,
51: 7,
52: 7,
53: 7,
54: 7,
55: 7,
56: 7,
57: 7,
58: 4,
59: 4,
60: 9,
61: 9,
62: 9,
63: 6,
64: 11,
65: 8,
66: 8,
67: 8,
68: 8,
69: 7,
70: 6,
71: 9,
72: 8,
73: 3,
74: 3,
75: 7,
76: 6,
77: 9,
78: 8,
79: 9,
80: 7,
81: 9,
82: 8,
83: 7,
84: 7,
85: 8,
86: 8,
87: 11,
88: 8,
89: 7,
90: 8,
91: 4,
92: 4,
93: 4,
94: 9,
95: 6,
96: 6,
97: 7,
98: 7,
99: 6,
100: 7,
101: 7,
102: 4,
103: 7,
104: 7,
105: 3,
106: 3,
107: 6,
108: 3,
109: 11,
110: 7,
111: 7,
112: 7,
113: 7,
114: 5,
115: 6,
116: 4,
117: 7,
118: 7,
119: 9,
120: 7,
121: 7,
122: 6,
123: 7,
124: 4,
125: 7,
126: 9,
161: 4,
162: 7,
163: 7,
164: 7,
165: 7,
166: 4,
167: 6,
168: 6,
169: 11,
170: 5,
171: 7,
172: 9,
174: 11,
175: 6,
176: 6,
177: 9,
178: 4,
179: 4,
180: 6,
181: 7,
182: 7,
183: 3,
184: 6,
185: 4,
186: 5,
187: 7,
188: 11,
189: 11,
190: 11,
191: 6,
192: 8,
193: 8,
194: 8,
195: 8,
196: 8,
197: 8,
198: 11,
199: 8,
200: 7,
201: 7,
202: 7,
203: 7,
204: 3,
205: 3,
206: 3,
207: 3,
208: 9,
209: 8,
210: 9,
211: 9,
212: 9,
213: 9,
214: 9,
215: 9,
216: 9,
217: 8,
218: 8,
219: 8,
220: 8,
221: 7,
222: 7,
223: 7,
224: 7,
225: 7,
226: 7,
227: 7,
228: 7,
229: 7,
230: 11,
231: 6,
232: 7,
233: 7,
234: 7,
235: 7,
236: 3,
237: 3,
238: 3,
239: 3,
240: 7,
241: 7,
242: 7,
243: 7,
244: 7,
245: 7,
246: 7,
247: 9,
248: 7,
249: 7,
250: 7,
251: 7,
252: 7,
253: 7,
254: 7,
255: 7,
}
})

View File

@ -4,16 +4,21 @@
package devtest
import (
"html/template"
"net/http"
"path"
"strconv"
"strings"
"time"
"unicode"
"code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/badge"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
)
@ -45,84 +50,121 @@ func FetchActionTest(ctx *context.Context) {
ctx.JSONRedirect("")
}
func prepareMockData(ctx *context.Context) {
if ctx.Req.URL.Path == "/devtest/gitea-ui" {
now := time.Now()
ctx.Data["TimeNow"] = now
ctx.Data["TimePast5s"] = now.Add(-5 * time.Second)
ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second)
ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute)
ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute)
ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second)
ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second)
func prepareMockDataGiteaUI(ctx *context.Context) {
now := time.Now()
ctx.Data["TimeNow"] = now
ctx.Data["TimePast5s"] = now.Add(-5 * time.Second)
ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second)
ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute)
ctx.Data["TimeFuture2m"] = now.Add(2 * time.Minute)
ctx.Data["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second)
ctx.Data["TimeFuture1y"] = now.Add(1 * 366 * 86400 * time.Second)
}
func prepareMockDataBadgeCommitSign(ctx *context.Context) {
var commits []*asymkey.SignCommit
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}})
mockUser := mockUsers[0]
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{},
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
TrustStatus: "trusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "untrusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "other(unmatch)",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Warning: true,
Reason: "gpg.error",
SigningEmail: "test@example.com",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
ctx.Data["MockCommits"] = commits
}
func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
fontFamilyNames := strings.Split(badge.DefaultFontFamily, ",")
selectedFontFamilyName := ctx.FormString("font", fontFamilyNames[0])
var badges []badge.Badge
badges = append(badges, badge.GenerateBadge("啊啊啊啊啊啊啊啊啊啊啊啊", "🌞🌞🌞🌞🌞", "green"))
for r := rune(0); r < 256; r++ {
if unicode.IsPrint(r) {
s := strings.Repeat(string(r), 15)
badges = append(badges, badge.GenerateBadge(s, util.TruncateRunes(s, 7), "green"))
}
}
if ctx.Req.URL.Path == "/devtest/commit-sign-badge" {
var commits []*asymkey.SignCommit
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}})
mockUser := mockUsers[0]
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{},
UserCommit: &user_model.UserCommit{
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningKey: &asymkey.GPGKey{KeyID: "12345678"},
TrustStatus: "trusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "untrusted",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Verified: true,
Reason: "name / key-id",
SigningUser: mockUser,
SigningSSHKey: &asymkey.PublicKey{Fingerprint: "aa:bb:cc:dd:ee"},
TrustStatus: "other(unmatch)",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
commits = append(commits, &asymkey.SignCommit{
Verification: &asymkey.CommitVerification{
Warning: true,
Reason: "gpg.error",
SigningEmail: "test@example.com",
},
UserCommit: &user_model.UserCommit{
User: mockUser,
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()},
},
})
var badgeSVGs []template.HTML
for i, b := range badges {
b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-"
b.FontFamily = selectedFontFamilyName
h, err := ctx.RenderToHTML("shared/actions/runner_badge", map[string]any{"Badge": b})
if err != nil {
ctx.ServerError("RenderToHTML", err)
return
}
badgeSVGs = append(badgeSVGs, h)
}
ctx.Data["BadgeSVGs"] = badgeSVGs
ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames
ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName
}
ctx.Data["MockCommits"] = commits
func prepareMockData(ctx *context.Context) {
switch ctx.Req.URL.Path {
case "/devtest/gitea-ui":
prepareMockDataGiteaUI(ctx)
case "/devtest/badge-commit-sign":
prepareMockDataBadgeCommitSign(ctx)
case "/devtest/badge-actions-svg":
prepareMockDataBadgeActionsSvg(ctx)
}
}
func Tmpl(ctx *context.Context) {
func TmplCommon(ctx *context.Context) {
prepareMockData(ctx)
if ctx.Req.Method == "POST" {
_ = ctx.Req.ParseForm()

View File

@ -1639,7 +1639,7 @@ func registerRoutes(m *web.Router) {
m.Group("/devtest", func() {
m.Any("", devtest.List)
m.Any("/fetch-action-test", devtest.FetchActionTest)
m.Any("/{sub}", devtest.Tmpl)
m.Any("/{sub}", devtest.TmplCommon)
m.Get("/repo-action-view/{run}/{job}", devtest.MockActionsView)
m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs)
})

View File

@ -0,0 +1,18 @@
{{template "devtest/devtest-header"}}
<div class="page-content devtest ui container">
<div>
<h1>Actions SVG</h1>
<form class="tw-my-3">
{{range $fontName := .BadgeFontFamilyNames}}
<label><input name="font" type="radio" value="{{$fontName}}" {{Iif (eq $.SelectedFontFamilyName $fontName) "checked"}}>{{$fontName}}</label>
{{end}}
<button>submit</button>
</form>
<div class="flex-text-block tw-flex-wrap">
{{range $badgeSVG := .BadgeSVGs}}
<div>{{$badgeSVG}}</div>
{{end}}
</div>
</div>
</div>
{{template "devtest/devtest-footer"}}

View File

@ -1,25 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{{.Badge.Width}}" height="18"
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{{.Badge.Width}}" height="20"
role="img" aria-label="{{.Badge.Label.Text}}: {{.Badge.Message.Text}}">
<title>{{.Badge.Label.Text}}: {{.Badge.Message.Text}}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7" />
<stop offset=".1" stop-color="#aaa" stop-opacity=".1" />
<stop offset=".9" stop-color="#000" stop-opacity=".3" />
<stop offset="1" stop-color="#000" stop-opacity=".5" />
<linearGradient id="{{.Badge.IDPrefix}}s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
<stop offset="1" stop-opacity=".1" />
</linearGradient>
<clipPath id="r">
<rect width="{{.Badge.Width}}" height="18" rx="4" fill="#fff" />
<clipPath id="{{.Badge.IDPrefix}}r">
<rect width="{{.Badge.Width}}" height="20" rx="3" fill="#fff" />
</clipPath>
<g clip-path="url(#r)">
<rect width="{{.Badge.Label.Width}}" height="18" fill="#555" />
<rect x="{{.Badge.Label.Width}}" width="{{.Badge.Message.Width}}" height="18" fill="{{.Badge.Color}}" />
<rect width="{{.Badge.Width}}" height="18" fill="url(#s)" />
<g clip-path="url(#{{.Badge.IDPrefix}}r)">
<rect width="{{.Badge.Label.Width}}" height="20" fill="#555" />
<rect x="{{.Badge.Label.Width}}" width="{{.Badge.Message.Width}}" height="20" fill="{{.Badge.Color}}" />
<rect width="{{.Badge.Width}}" height="20" fill="url(#{{.Badge.IDPrefix}}s)" />
</g>
<g fill="#fff" text-anchor="middle" font-family="{{.Badge.FontFamily}}"
text-rendering="geometricPrecision" font-size="{{.Badge.FontSize}}">
<text aria-hidden="true" x="{{.Badge.Label.X}}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)"
textLength="{{.Badge.Label.TextLength}}">{{.Badge.Label.Text}}</text>
<text x="{{.Badge.Label.X}}" y="140"
transform="scale(.1)" fill="#fff" textLength="{{.Badge.Label.TextLength}}">{{.Badge.Label.Text}}</text>
<text aria-hidden="true" x="{{.Badge.Message.X}}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)"
textLength="{{.Badge.Message.TextLength}}">{{.Badge.Message.Text}}</text>
<text x="{{.Badge.Message.X}}" y="140" transform="scale(.1)" fill="#fff"
textLength="{{.Badge.Message.TextLength}}">{{.Badge.Message.Text}}</text>
</g>
<g fill="#fff" text-anchor="middle" font-family="Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision"
font-size="{{.Badge.FontSize}}"><text aria-hidden="true" x="{{.Badge.Label.X}}" y="140" fill="#010101" fill-opacity=".3"
transform="scale(.1)" textLength="{{.Badge.Label.TextLength}}">{{.Badge.Label.Text}}</text><text x="{{.Badge.Label.X}}" y="130"
transform="scale(.1)" fill="#fff" textLength="{{.Badge.Label.TextLength}}">{{.Badge.Label.Text}}</text><text aria-hidden="true"
x="{{.Badge.Message.X}}" y="140" fill="#010101" fill-opacity=".3" transform="scale(.1)"
textLength="{{.Badge.Message.TextLength}}">{{.Badge.Message.Text}}</text><text x="{{.Badge.Message.X}}" y="130" transform="scale(.1)"
fill="#fff" textLength="{{.Badge.Message.TextLength}}">{{.Badge.Message.Text}}</text></g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB