- xbps-src templates: das-theme (adw-gtk3-based GTK themes, generated xfwm4 frames, wallpapers, logo), das-settings (Xfce, panel, LightDM defaults in /etc/xdg/das), adw-gtk3 and font-jetbrains-mono - void-mklive profile in live/: packages, runit services, rootfs overlay, Ukrainian locale, live user with autologin - Void builder container, build scripts, QEMU runner and Makefile - Bilingual README with screenshots Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
344 lines
9.2 KiB
Go
344 lines
9.2 KiB
Go
// Command xfwm4gen renders the Das OS xfwm4 window decorations.
|
|
//
|
|
// xfwm4 themes are a directory of image slices plus a themerc file. Drawing
|
|
// ~90 slices by hand is error-prone, so they are generated from one palette:
|
|
//
|
|
// xfwm4gen -variant light -out build/DasOS/xfwm4
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
titleHeight = 32
|
|
frameWidth = 3
|
|
cornerRadius = 8.0
|
|
cornerWidth = 12
|
|
buttonWidth = 30
|
|
circleRadius = 11.0
|
|
glyphHalf = 4.0
|
|
strokeWidth = 1.5
|
|
samples = 4 // supersampling per axis, gives 16 samples per pixel
|
|
)
|
|
|
|
type palette struct {
|
|
titleBg, border, separator, glyph, text color.NRGBA
|
|
}
|
|
|
|
type variant struct {
|
|
active, inactive palette
|
|
hover, pressed color.NRGBA
|
|
closeHover, closePressed, onClose color.NRGBA
|
|
}
|
|
|
|
var variants = map[string]variant{
|
|
"light": {
|
|
active: palette{
|
|
titleBg: rgb(0xe9ecf1), border: rgb(0xb4bcc9), separator: rgb(0xd3d8e1),
|
|
glyph: rgb(0x2e3440), text: rgb(0x1f2430),
|
|
},
|
|
inactive: palette{
|
|
titleBg: rgb(0xf4f5f8), border: rgb(0xccd2db), separator: rgb(0xe2e5eb),
|
|
glyph: rgb(0x9aa1ad), text: rgb(0x8a909c),
|
|
},
|
|
hover: rgb(0xd5dae3), pressed: rgb(0xc3cad6),
|
|
closeHover: rgb(0xe0464c), closePressed: rgb(0xc2353b), onClose: rgb(0xffffff),
|
|
},
|
|
"dark": {
|
|
active: palette{
|
|
titleBg: rgb(0x2b2f38), border: rgb(0x0e1014), separator: rgb(0x1d2026),
|
|
glyph: rgb(0xe3e6ec), text: rgb(0xeceff4),
|
|
},
|
|
inactive: palette{
|
|
titleBg: rgb(0x24272e), border: rgb(0x15171b), separator: rgb(0x1b1d22),
|
|
glyph: rgb(0x7c8391), text: rgb(0x8a909c),
|
|
},
|
|
hover: rgb(0x3b404b), pressed: rgb(0x4a505d),
|
|
closeHover: rgb(0xe0464c), closePressed: rgb(0xc2353b), onClose: rgb(0xffffff),
|
|
},
|
|
}
|
|
|
|
func rgb(v uint32) color.NRGBA {
|
|
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xff}
|
|
}
|
|
|
|
func hex(c color.NRGBA) string {
|
|
return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B)
|
|
}
|
|
|
|
// shader returns the colour of a point in pixel coordinates; transparent outside the shape.
|
|
type shader func(x, y float64) color.NRGBA
|
|
|
|
// render rasterises a shader with supersampling, averaging in premultiplied space.
|
|
func render(w, h int, fn shader) *image.NRGBA {
|
|
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
|
const n = samples * samples
|
|
for py := 0; py < h; py++ {
|
|
for px := 0; px < w; px++ {
|
|
var r, g, b, a float64
|
|
for sy := 0; sy < samples; sy++ {
|
|
for sx := 0; sx < samples; sx++ {
|
|
c := fn(float64(px)+(float64(sx)+0.5)/samples, float64(py)+(float64(sy)+0.5)/samples)
|
|
ca := float64(c.A) / 255
|
|
r += float64(c.R) * ca
|
|
g += float64(c.G) * ca
|
|
b += float64(c.B) * ca
|
|
a += ca
|
|
}
|
|
}
|
|
if a == 0 {
|
|
continue
|
|
}
|
|
img.SetNRGBA(px, py, color.NRGBA{
|
|
R: uint8(math.Round(r / a)), G: uint8(math.Round(g / a)), B: uint8(math.Round(b / a)),
|
|
A: uint8(math.Round(a / n * 255)),
|
|
})
|
|
}
|
|
}
|
|
return img
|
|
}
|
|
|
|
// titleRow is the colour of the title bar at height y: top border, fill, separator above the client.
|
|
func titleRow(p palette, y float64) color.NRGBA {
|
|
switch {
|
|
case y < 1:
|
|
return p.border
|
|
case y >= titleHeight-1:
|
|
return p.separator
|
|
default:
|
|
return p.titleBg
|
|
}
|
|
}
|
|
|
|
// insideRounded reports whether (x, y) lies in a shape whose top-left corner is
|
|
// rounded with radius r at origin (ox, oy) and which extends right and down.
|
|
func insideRounded(x, y, ox, oy, r float64) bool {
|
|
if x < ox || y < oy {
|
|
return false
|
|
}
|
|
if x < ox+r && y < oy+r {
|
|
return math.Hypot(x-(ox+r), y-(oy+r)) <= r
|
|
}
|
|
return true
|
|
}
|
|
|
|
func topLeft(p palette) shader {
|
|
return func(x, y float64) color.NRGBA {
|
|
switch {
|
|
case !insideRounded(x, y, 0, 0, cornerRadius):
|
|
return color.NRGBA{}
|
|
case !insideRounded(x, y, 1, 1, cornerRadius-1):
|
|
return p.border
|
|
case x < frameWidth:
|
|
return p.titleBg
|
|
default:
|
|
return titleRow(p, y)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mirror(fn shader, w int) shader {
|
|
return func(x, y float64) color.NRGBA { return fn(float64(w)-x, y) }
|
|
}
|
|
|
|
func leftEdge(p palette) shader {
|
|
return func(x, _ float64) color.NRGBA {
|
|
if x < 1 {
|
|
return p.border
|
|
}
|
|
return p.titleBg
|
|
}
|
|
}
|
|
|
|
func bottomEdge(p palette) shader {
|
|
return func(_, y float64) color.NRGBA {
|
|
if y >= frameWidth-1 {
|
|
return p.border
|
|
}
|
|
return p.titleBg
|
|
}
|
|
}
|
|
|
|
func bottomLeft(p palette) shader {
|
|
return func(x, y float64) color.NRGBA {
|
|
if x < 1 || y >= frameWidth-1 {
|
|
return p.border
|
|
}
|
|
return p.titleBg
|
|
}
|
|
}
|
|
|
|
type segment struct{ x1, y1, x2, y2 float64 }
|
|
|
|
// glyph is a button symbol drawn around the button centre.
|
|
type glyph struct {
|
|
lines []segment
|
|
ring float64 // stroked circle radius, 0 for none
|
|
dot float64 // filled circle radius, 0 for none
|
|
}
|
|
|
|
func square(x1, y1, x2, y2 float64) []segment {
|
|
return []segment{{x1, y1, x2, y1}, {x2, y1, x2, y2}, {x2, y2, x1, y2}, {x1, y2, x1, y1}}
|
|
}
|
|
|
|
var glyphs = func() map[string]glyph {
|
|
const g = glyphHalf
|
|
return map[string]glyph{
|
|
"close": {lines: []segment{{-g, -g, g, g}, {-g, g, g, -g}}},
|
|
"hide": {lines: []segment{{-g, 3, g, 3}}},
|
|
"maximize": {lines: square(-g, -g, g, g)},
|
|
"maximize-toggled": {lines: append(square(-g, -g+2.5, g-2.5, g), segment{-g + 2.5, -g, g, -g}, segment{g, -g, g, g - 2.5})},
|
|
"menu": {lines: []segment{{-g, -3, g, -3}, {-g, 0, g, 0}, {-g, 3, g, 3}}},
|
|
"shade": {lines: []segment{{-g, 2, 0, -2}, {0, -2, g, 2}}},
|
|
"shade-toggled": {lines: []segment{{-g, -2, 0, 2}, {0, 2, g, -2}}},
|
|
"stick": {ring: g},
|
|
"stick-toggled": {dot: g},
|
|
}
|
|
}()
|
|
|
|
func distToSegment(px, py float64, s segment) float64 {
|
|
dx, dy := s.x2-s.x1, s.y2-s.y1
|
|
t := ((px-s.x1)*dx + (py-s.y1)*dy) / (dx*dx + dy*dy)
|
|
t = math.Max(0, math.Min(1, t))
|
|
return math.Hypot(px-(s.x1+t*dx), py-(s.y1+t*dy))
|
|
}
|
|
|
|
func (g glyph) covers(x, y float64) bool {
|
|
for _, s := range g.lines {
|
|
if distToSegment(x, y, s) <= strokeWidth/2 {
|
|
return true
|
|
}
|
|
}
|
|
r := math.Hypot(x, y)
|
|
if g.ring > 0 && math.Abs(r-g.ring) <= strokeWidth/2 {
|
|
return true
|
|
}
|
|
return g.dot > 0 && r <= g.dot
|
|
}
|
|
|
|
func button(p palette, g glyph, circle, ink color.NRGBA) shader {
|
|
cx, cy := buttonWidth/2.0, titleHeight/2.0
|
|
return func(x, y float64) color.NRGBA {
|
|
dx, dy := x-cx, y-cy
|
|
switch {
|
|
case g.covers(dx, dy):
|
|
return ink
|
|
case circle.A != 0 && math.Hypot(dx, dy) <= circleRadius:
|
|
return circle
|
|
default:
|
|
return titleRow(p, y)
|
|
}
|
|
}
|
|
}
|
|
|
|
type piece struct {
|
|
name string
|
|
w, h int
|
|
fn shader
|
|
}
|
|
|
|
func pieces(v variant) []piece {
|
|
var out []piece
|
|
for _, state := range []string{"active", "inactive"} {
|
|
p := v.active
|
|
if state == "inactive" {
|
|
p = v.inactive
|
|
}
|
|
out = append(out,
|
|
piece{"top-left-" + state, cornerWidth, titleHeight, topLeft(p)},
|
|
piece{"top-right-" + state, cornerWidth, titleHeight, mirror(topLeft(p), cornerWidth)},
|
|
piece{"left-" + state, frameWidth, 1, leftEdge(p)},
|
|
piece{"right-" + state, frameWidth, 1, mirror(leftEdge(p), frameWidth)},
|
|
piece{"bottom-" + state, 1, frameWidth, bottomEdge(p)},
|
|
piece{"bottom-left-" + state, frameWidth, frameWidth, bottomLeft(p)},
|
|
piece{"bottom-right-" + state, frameWidth, frameWidth, mirror(bottomLeft(p), frameWidth)},
|
|
)
|
|
for i := 1; i <= 5; i++ {
|
|
out = append(out, piece{fmt.Sprintf("title-%d-%s", i, state), 1, titleHeight,
|
|
func(_, y float64) color.NRGBA { return titleRow(p, y) }})
|
|
}
|
|
}
|
|
for name, g := range glyphs {
|
|
hover, pressed, ink := v.hover, v.pressed, v.active.glyph
|
|
if name == "close" {
|
|
hover, pressed, ink = v.closeHover, v.closePressed, v.onClose
|
|
}
|
|
out = append(out,
|
|
piece{name + "-active", buttonWidth, titleHeight, button(v.active, g, color.NRGBA{}, v.active.glyph)},
|
|
piece{name + "-inactive", buttonWidth, titleHeight, button(v.inactive, g, color.NRGBA{}, v.inactive.glyph)},
|
|
piece{name + "-prelight", buttonWidth, titleHeight, button(v.active, g, hover, ink)},
|
|
piece{name + "-pressed", buttonWidth, titleHeight, button(v.active, g, pressed, ink)},
|
|
)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func themerc(v variant) string {
|
|
return strings.Join([]string{
|
|
"# Generated by xfwm4gen, do not edit.",
|
|
"active_text_color=" + hex(v.active.text),
|
|
"inactive_text_color=" + hex(v.inactive.text),
|
|
"button_offset=4",
|
|
"button_spacing=0",
|
|
"full_width_title=true",
|
|
"maximized_offset=0",
|
|
"show_app_icon=true",
|
|
"title_alignment=center",
|
|
"title_horizontal_offset=0",
|
|
"title_shadow_active=false",
|
|
"title_shadow_inactive=false",
|
|
"title_vertical_offset_active=0",
|
|
"title_vertical_offset_inactive=0",
|
|
"",
|
|
}, "\n")
|
|
}
|
|
|
|
func writePNG(path string, img image.Image) error {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := png.Encode(f, img); err != nil {
|
|
f.Close()
|
|
return fmt.Errorf("encode %s: %w", path, err)
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
func generate(v variant, dir string) error {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
for _, p := range pieces(v) {
|
|
if err := writePNG(filepath.Join(dir, p.name+".png"), render(p.w, p.h, p.fn)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return os.WriteFile(filepath.Join(dir, "themerc"), []byte(themerc(v)), 0o644)
|
|
}
|
|
|
|
func main() {
|
|
name := flag.String("variant", "light", "colour variant: light or dark")
|
|
out := flag.String("out", "", "output directory (the theme's xfwm4 folder)")
|
|
flag.Parse()
|
|
|
|
v, ok := variants[*name]
|
|
if !ok || *out == "" {
|
|
flag.Usage()
|
|
os.Exit(2)
|
|
}
|
|
if err := generate(v, *out); err != nil {
|
|
log.Fatalf("xfwm4gen: %v", err)
|
|
}
|
|
}
|