Add flat-square action badge style (#34062)

Adds the `flat-square` style to action badges. Styles can be selected by
adding `?style=<style>` to the badge endpoint. If no style query is
given, or if the query is invalid, the style defaults to `flat`.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
bytedream 2025-04-01 11:42:10 +02:00 committed by GitHub
parent 86c1a33369
commit 56e42be36d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 84 additions and 30 deletions

View File

@ -5,6 +5,7 @@ package badge
import ( import (
"strings" "strings"
"sync"
"unicode" "unicode"
actions_model "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions"
@ -49,23 +50,40 @@ func (b Badge) Width() int {
return b.Label.width + b.Message.width return b.Label.width + b.Message.width
} }
// Style follows https://shields.io/badges
const (
StyleFlat = "flat"
StyleFlatSquare = "flat-square"
)
const ( const (
defaultOffset = 10 defaultOffset = 10
defaultFontSize = 11 defaultFontSize = 11
DefaultColor = "#9f9f9f" // Grey DefaultColor = "#9f9f9f" // Grey
DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif" DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif"
DefaultStyle = StyleFlat
) )
var StatusColorMap = map[actions_model.Status]string{ var GlobalVars = sync.OnceValue(func() (ret struct {
actions_model.StatusSuccess: "#4c1", // Green StatusColorMap map[actions_model.Status]string
actions_model.StatusSkipped: "#dfb317", // Yellow DejaVuGlyphWidthData map[rune]uint8
actions_model.StatusUnknown: "#97ca00", // Light Green AllStyles []string
actions_model.StatusFailure: "#e05d44", // Red },
actions_model.StatusCancelled: "#fe7d37", // Orange ) {
actions_model.StatusWaiting: "#dfb317", // Yellow ret.StatusColorMap = map[actions_model.Status]string{
actions_model.StatusRunning: "#dfb317", // Yellow actions_model.StatusSuccess: "#4c1", // Green
actions_model.StatusBlocked: "#dfb317", // Yellow actions_model.StatusSkipped: "#dfb317", // Yellow
} actions_model.StatusUnknown: "#97ca00", // Light Green
actions_model.StatusFailure: "#e05d44", // Red
actions_model.StatusCancelled: "#fe7d37", // Orange
actions_model.StatusWaiting: "#dfb317", // Yellow
actions_model.StatusRunning: "#dfb317", // Yellow
actions_model.StatusBlocked: "#dfb317", // Yellow
}
ret.DejaVuGlyphWidthData = dejaVuGlyphWidthDataFunc()
ret.AllStyles = []string{StyleFlat, StyleFlatSquare}
return ret
})
// 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 {
@ -93,7 +111,7 @@ func GenerateBadge(label, message, color string) Badge {
func calculateTextWidth(text string) int { func calculateTextWidth(text string) int {
width := 0 width := 0
widthData := DejaVuGlyphWidthData() widthData := GlobalVars().DejaVuGlyphWidthData
for _, char := range strings.TrimSpace(text) { for _, char := range strings.TrimSpace(text) {
charWidth, ok := widthData[char] charWidth, ok := widthData[char]
if !ok { if !ok {

View File

@ -3,8 +3,6 @@
package badge package badge
import "sync"
// DejaVuGlyphWidthData is generated by `sfnt.Face.GlyphAdvance(nil, <rune>, 11, font.HintingNone)` with DejaVu Sans // 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). // v2.37 (https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-sans-ttf-2.37.zip).
// //
@ -13,7 +11,7 @@ import "sync"
// //
// A devtest page "/devtest/badge-actions-svg" could be used to check the rendered images. // A devtest page "/devtest/badge-actions-svg" could be used to check the rendered images.
var DejaVuGlyphWidthData = sync.OnceValue(func() map[rune]uint8 { func dejaVuGlyphWidthDataFunc() map[rune]uint8 {
return map[rune]uint8{ return map[rune]uint8{
32: 3, 32: 3,
33: 4, 33: 4,
@ -205,4 +203,4 @@ var DejaVuGlyphWidthData = sync.OnceValue(func() map[rune]uint8 {
254: 7, 254: 7,
255: 7, 255: 7,
} }
}) }

View File

@ -4,6 +4,7 @@
package devtest package devtest
import ( import (
"fmt"
"html/template" "html/template"
"net/http" "net/http"
"path" "path"
@ -128,6 +129,7 @@ func prepareMockDataBadgeCommitSign(ctx *context.Context) {
func prepareMockDataBadgeActionsSvg(ctx *context.Context) { func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
fontFamilyNames := strings.Split(badge.DefaultFontFamily, ",") fontFamilyNames := strings.Split(badge.DefaultFontFamily, ",")
selectedFontFamilyName := ctx.FormString("font", fontFamilyNames[0]) selectedFontFamilyName := ctx.FormString("font", fontFamilyNames[0])
selectedStyle := ctx.FormString("style", badge.DefaultStyle)
var badges []badge.Badge var badges []badge.Badge
badges = append(badges, badge.GenerateBadge("啊啊啊啊啊啊啊啊啊啊啊啊", "🌞🌞🌞🌞🌞", "green")) badges = append(badges, badge.GenerateBadge("啊啊啊啊啊啊啊啊啊啊啊啊", "🌞🌞🌞🌞🌞", "green"))
for r := rune(0); r < 256; r++ { for r := rune(0); r < 256; r++ {
@ -141,7 +143,16 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
for i, b := range badges { for i, b := range badges {
b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-" b.IDPrefix = "devtest-" + strconv.FormatInt(int64(i), 10) + "-"
b.FontFamily = selectedFontFamilyName b.FontFamily = selectedFontFamilyName
h, err := ctx.RenderToHTML("shared/actions/runner_badge", map[string]any{"Badge": b}) var h template.HTML
var err error
switch selectedStyle {
case badge.StyleFlat:
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat", map[string]any{"Badge": b})
case badge.StyleFlatSquare:
h, err = ctx.RenderToHTML("shared/actions/runner_badge_flat-square", map[string]any{"Badge": b})
default:
err = fmt.Errorf("unknown badge style: %s", selectedStyle)
}
if err != nil { if err != nil {
ctx.ServerError("RenderToHTML", err) ctx.ServerError("RenderToHTML", err)
return return
@ -151,6 +162,8 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) {
ctx.Data["BadgeSVGs"] = badgeSVGs ctx.Data["BadgeSVGs"] = badgeSVGs
ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames ctx.Data["BadgeFontFamilyNames"] = fontFamilyNames
ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName ctx.Data["SelectedFontFamilyName"] = selectedFontFamilyName
ctx.Data["BadgeStyles"] = badge.GlobalVars().AllStyles
ctx.Data["SelectedStyle"] = selectedStyle
} }
func prepareMockData(ctx *context.Context) { func prepareMockData(ctx *context.Context) {

View File

@ -5,35 +5,38 @@ package actions
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
actions_model "code.gitea.io/gitea/models/actions" actions_model "code.gitea.io/gitea/models/actions"
"code.gitea.io/gitea/modules/badge" "code.gitea.io/gitea/modules/badge"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
) )
func GetWorkflowBadge(ctx *context.Context) { func GetWorkflowBadge(ctx *context.Context) {
workflowFile := ctx.PathParam("workflow_name") workflowFile := ctx.PathParam("workflow_name")
branch := ctx.Req.URL.Query().Get("branch") branch := ctx.FormString("branch", ctx.Repo.Repository.DefaultBranch)
if branch == "" { event := ctx.FormString("event")
branch = ctx.Repo.Repository.DefaultBranch style := ctx.FormString("style")
}
branchRef := fmt.Sprintf("refs/heads/%s", branch)
event := ctx.Req.URL.Query().Get("event")
badge, err := getWorkflowBadge(ctx, workflowFile, branchRef, event) branchRef := git.RefNameFromBranch(branch)
b, err := getWorkflowBadge(ctx, workflowFile, branchRef.String(), event)
if err != nil { if err != nil {
ctx.ServerError("GetWorkflowBadge", err) ctx.ServerError("GetWorkflowBadge", err)
return return
} }
ctx.Data["Badge"] = badge ctx.Data["Badge"] = b
ctx.RespHeader().Set("Content-Type", "image/svg+xml") ctx.RespHeader().Set("Content-Type", "image/svg+xml")
ctx.HTML(http.StatusOK, "shared/actions/runner_badge") switch style {
case badge.StyleFlatSquare:
ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat-square")
default: // defaults to badge.StyleFlat
ctx.HTML(http.StatusOK, "shared/actions/runner_badge_flat")
}
} }
func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event string) (badge.Badge, error) { func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event string) (badge.Badge, error) {
@ -48,7 +51,7 @@ func getWorkflowBadge(ctx *context.Context, workflowFile, branchName, event stri
return badge.Badge{}, err return badge.Badge{}, err
} }
color, ok := badge.StatusColorMap[run.Status] color, ok := badge.GlobalVars().StatusColorMap[run.Status]
if !ok { if !ok {
return badge.GenerateBadge(workflowName, "unknown status", badge.DefaultColor), nil return badge.GenerateBadge(workflowName, "unknown status", badge.DefaultColor), nil
} }

View File

@ -3,9 +3,16 @@
<div> <div>
<h1>Actions SVG</h1> <h1>Actions SVG</h1>
<form class="tw-my-3"> <form class="tw-my-3">
{{range $fontName := .BadgeFontFamilyNames}} <div class="tw-mb-2">
<label><input name="font" type="radio" value="{{$fontName}}" {{Iif (eq $.SelectedFontFamilyName $fontName) "checked"}}>{{$fontName}}</label> {{range $fontName := .BadgeFontFamilyNames}}
{{end}} <label><input name="font" type="radio" value="{{$fontName}}" {{Iif (eq $.SelectedFontFamilyName $fontName) "checked"}}>{{$fontName}}</label>
{{end}}
</div>
<div class="tw-mb-2">
{{range $style := .BadgeStyles}}
<label><input name="style" type="radio" value="{{$style}}" {{Iif (eq $.SelectedStyle $style) "checked"}}>{{$style}}</label>
{{end}}
</div>
<button>submit</button> <button>submit</button>
</form> </form>
<div class="flex-text-block tw-flex-wrap"> <div class="flex-text-block tw-flex-wrap">

View File

@ -0,0 +1,15 @@
<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>
<g shape-rendering="crispEdges">
<rect width="{{.Badge.Label.Width}}" height="20" fill="#555" />
<rect x="{{.Badge.Label.Width}}" width="{{.Badge.Message.Width}}" height="20" fill="{{.Badge.Color}}" />
</g>
<g fill="#fff" text-anchor="middle" font-family="{{.Badge.FontFamily}}"
text-rendering="geometricPrecision" font-size="{{.Badge.FontSize}}">
<text x="{{.Badge.Label.X}}" y="140"
transform="scale(.1)" fill="#fff" textLength="{{.Badge.Label.TextLength}}">{{.Badge.Label.Text}}</text>
<text x="{{.Badge.Message.X}}" y="140" transform="scale(.1)" fill="#fff"
textLength="{{.Badge.Message.TextLength}}">{{.Badge.Message.Text}}</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 924 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB