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>
This commit is contained in:
bytedream
2025-03-28 16:12:47 +01:00
committed by GitHub
parent 0d2607a303
commit bf9500b3f2
7 changed files with 413 additions and 136 deletions

View File

@ -4,6 +4,9 @@
package badge package badge
import ( import (
"strings"
"unicode"
actions_model "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions"
) )
@ -11,54 +14,35 @@ import (
// We use 10x scale to calculate more precisely // We use 10x scale to calculate more precisely
// Then scale down to normal size in tmpl file // Then scale down to normal size in tmpl file
type Label struct { type Text 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 {
text string text string
width int width int
x int x int
} }
func (m Message) Text() string { func (t Text) Text() string {
return m.text return t.text
} }
func (m Message) Width() int { func (t Text) Width() int {
return m.width return t.width
} }
func (m Message) X() int { func (t Text) X() int {
return m.x return t.x
} }
func (m Message) TextLength() int { func (t Text) TextLength() int {
return int(float64(m.width-defaultOffset) * 9.5) return int(float64(t.width-defaultOffset) * 10)
} }
type Badge struct { type Badge struct {
Color string IDPrefix string
FontSize int FontFamily string
Label Label Color string
Message Message FontSize int
Label Text
Message Text
} }
func (b Badge) Width() int { func (b Badge) Width() int {
@ -66,10 +50,10 @@ func (b Badge) Width() int {
} }
const ( const (
defaultOffset = 9 defaultOffset = 10
defaultFontSize = 11 defaultFontSize = 11
DefaultColor = "#9f9f9f" // Grey DefaultColor = "#9f9f9f" // Grey
defaultFontWidth = 7 // approximate speculation DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif"
) )
var StatusColorMap = map[actions_model.Status]string{ var StatusColorMap = map[actions_model.Status]string{
@ -85,20 +69,43 @@ var StatusColorMap = map[actions_model.Status]string{
// GenerateBadge generates badge with given template // GenerateBadge generates badge with given template
func GenerateBadge(label, message, color string) Badge { func GenerateBadge(label, message, color string) Badge {
lw := defaultFontWidth*len(label) + defaultOffset lw := calculateTextWidth(label) + defaultOffset
mw := defaultFontWidth*len(message) + defaultOffset mw := calculateTextWidth(message) + defaultOffset
x := lw*10 + mw*5 - 10
lx := lw * 5
mx := lw*10 + mw*5 - 10
return Badge{ return Badge{
Label: Label{ FontFamily: DefaultFontFamily,
Label: Text{
text: label, text: label,
width: lw, width: lw,
x: lx,
}, },
Message: Message{ Message: Text{
text: message, text: message,
width: mw, width: mw,
x: x, x: mx,
}, },
FontSize: defaultFontSize * 10, FontSize: defaultFontSize * 10,
Color: color, 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 package devtest
import ( import (
"html/template"
"net/http" "net/http"
"path" "path"
"strconv"
"strings" "strings"
"time" "time"
"unicode"
"code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/asymkey"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user" 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/git"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
) )
@ -45,84 +50,121 @@ func FetchActionTest(ctx *context.Context) {
ctx.JSONRedirect("") ctx.JSONRedirect("")
} }
func prepareMockData(ctx *context.Context) { func prepareMockDataGiteaUI(ctx *context.Context) {
if ctx.Req.URL.Path == "/devtest/gitea-ui" { now := time.Now()
now := time.Now() ctx.Data["TimeNow"] = now
ctx.Data["TimeNow"] = now ctx.Data["TimePast5s"] = now.Add(-5 * time.Second)
ctx.Data["TimePast5s"] = now.Add(-5 * time.Second) ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second)
ctx.Data["TimeFuture5s"] = now.Add(5 * time.Second) ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute)
ctx.Data["TimePast2m"] = now.Add(-2 * time.Minute) ctx.Data["TimeFuture2m"] = 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["TimePast1y"] = now.Add(-1 * 366 * 86400 * time.Second) ctx.Data["TimeFuture1y"] = 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 badgeSVGs []template.HTML
var commits []*asymkey.SignCommit for i, b := range badges {
mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 1}}) b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-"
mockUser := mockUsers[0] b.FontFamily = selectedFontFamilyName
commits = append(commits, &asymkey.SignCommit{ h, err := ctx.RenderToHTML("shared/actions/runner_badge", map[string]any{"Badge": b})
Verification: &asymkey.CommitVerification{}, if err != nil {
UserCommit: &user_model.UserCommit{ ctx.ServerError("RenderToHTML", err)
Commit: &git.Commit{ID: git.Sha1ObjectFormat.EmptyObjectID()}, return
}, }
}) badgeSVGs = append(badgeSVGs, h)
commits = append(commits, &asymkey.SignCommit{ }
Verification: &asymkey.CommitVerification{ ctx.Data["BadgeSVGs"] = badgeSVGs
Verified: true, ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames
Reason: "name / key-id", ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName
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 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) prepareMockData(ctx)
if ctx.Req.Method == "POST" { if ctx.Req.Method == "POST" {
_ = ctx.Req.ParseForm() _ = ctx.Req.ParseForm()

View File

@ -1639,7 +1639,7 @@ func registerRoutes(m *web.Router) {
m.Group("/devtest", func() { m.Group("/devtest", func() {
m.Any("", devtest.List) m.Any("", devtest.List)
m.Any("/fetch-action-test", devtest.FetchActionTest) 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.Get("/repo-action-view/{run}/{job}", devtest.MockActionsView)
m.Post("/actions-mock/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) 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}}"> role="img" aria-label="{{.Badge.Label.Text}}: {{.Badge.Message.Text}}">
<title>{{.Badge.Label.Text}}: {{.Badge.Message.Text}}</title> <title>{{.Badge.Label.Text}}: {{.Badge.Message.Text}}</title>
<linearGradient id="s" x2="0" y2="100%"> <linearGradient id="{{.Badge.IDPrefix}}s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7" /> <stop offset="0" stop-color="#bbb" stop-opacity=".1" />
<stop offset=".1" stop-color="#aaa" stop-opacity=".1" /> <stop offset="1" stop-opacity=".1" />
<stop offset=".9" stop-color="#000" stop-opacity=".3" />
<stop offset="1" stop-color="#000" stop-opacity=".5" />
</linearGradient> </linearGradient>
<clipPath id="r"> <clipPath id="{{.Badge.IDPrefix}}r">
<rect width="{{.Badge.Width}}" height="18" rx="4" fill="#fff" /> <rect width="{{.Badge.Width}}" height="20" rx="3" fill="#fff" />
</clipPath> </clipPath>
<g clip-path="url(#r)"> <g clip-path="url(#{{.Badge.IDPrefix}}r)">
<rect width="{{.Badge.Label.Width}}" height="18" fill="#555" /> <rect width="{{.Badge.Label.Width}}" height="20" fill="#555" />
<rect x="{{.Badge.Label.Width}}" width="{{.Badge.Message.Width}}" height="18" fill="{{.Badge.Color}}" /> <rect x="{{.Badge.Label.Width}}" width="{{.Badge.Message.Width}}" height="20" fill="{{.Badge.Color}}" />
<rect width="{{.Badge.Width}}" height="18" fill="url(#s)" /> <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>
<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> </svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB