Skip to content

Instantly share code, notes, and snippets.

@IceflowRE
Created February 2, 2025 02:56
Show Gist options
  • Save IceflowRE/7bdac6921a97de6382376dd24dea90e2 to your computer and use it in GitHub Desktop.
Save IceflowRE/7bdac6921a97de6382376dd24dea90e2 to your computer and use it in GitHub Desktop.
Build Breeze Cursor for Windows

Requirements Go, Python and Inkscape.

git clone https://invent.kde.org/plasma/breeze
python3 -m venv .venv
source .venv/bin/activate
python -m pip install clickgen
sudo apt-get update
sudo apt-get install inkscape
go run main.go
; {{.Theme}} {{.Version}} cursors
[Version]
signature="$CHICAGO$"
{{.Theme}} {{.Version}}
[DefaultInstall]
CopyFiles = Scheme.Cur
AddReg = Scheme.Reg
[DestinationDirs]
Scheme.Cur = 10,"%CUR_DIR%"
[Scheme.Reg]
HKCU,"Control Panel\Cursors\Schemes","%SCHEME_NAME%",,"%10%\%CUR_DIR%\%pointer%,%10%\%CUR_DIR%\%help%,%10%\%CUR_DIR%\%work%,%10%\%CUR_DIR%\%busy%,%10%\%CUR_DIR%\%cross%,%10%\%CUR_DIR%\%Text%,%10%\%CUR_DIR%\%Hand%,%10%\%CUR_DIR%\%unavailable%,%10%\%CUR_DIR%\%Vert%,%10%\%CUR_DIR%\%horz%,%10%\%CUR_DIR%\%Dgn1%,%10%\%CUR_DIR%\%Dgn2%,%10%\%CUR_DIR%\%move%,%10%\%CUR_DIR%\%alternate%,%10%\%CUR_DIR%\%link%"
; -- Installed files
[Scheme.Cur]
default.cur
help.cur
wait.ani
progress.ani
text.cur
not-allowed.cur
size_ver.cur
size_hor.cur
size_fdiag.cur
size_bdiag.cur
all-scroll.cur
pointer.cur
crosshair.cur
pencil.cur
center_ptr.cur
[Strings]
CUR_DIR = "Cursors\{{.Theme}} {{.Version}}"
SCHEME_NAME = "{{.Theme}} {{.Version}}"
pointer = "default.cur"
help = "help.cur"
work = "progress.ani"
busy = "wait.ani"
text = "text.cur"
unavailable = "not-allowed.cur"
vert = "size_ver.cur"
horz = "size_hor.cur"
dgn1 = "size_fdiag.cur"
dgn2 = "size_bdiag.cur"
move = "all-scroll.cur"
link = "pointer.cur"
cross = "crosshair.cur"
hand = "pencil.cur"
alternate = "center_ptr.cur"
package main
/*
Iceflower - [email protected]
MIT License
*/
import (
_ "embed"
"errors"
"fmt"
"log"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"text/template"
)
const (
svgBaseSize = 32
exportSize = 256
imageOutputDir = "./export"
cursorOutputDir = "./cursors"
//svgSource = "./breeze/cursors/BreezeLight/src/svg/"
//svgSource = "./breeze/cursors/Breeze/src/svg/"
animDelayMs = 2
)
var breezeThemes = []string{"Breeze", "Breeze_Light"}
const version = "6.3"
var cursorFiles = [][]string{
{"all-scroll"},
{"size_fdiag"},
{"crosshair"},
{"not-allowed"},
{"size_bdiag"},
{"pointer"},
{"default"},
{"progress-01", "progress-02", "progress-03", "progress-04", "progress-05", "progress-06", "progress-07", "progress-08", "progress-09", "progress-10", "progress-11", "progress-12", "progress-13", "progress-14", "progress-15", "progress-16", "progress-17", "progress-18", "progress-19", "progress-20", "progress-21", "progress-22", "progress-23"},
{"pencil"},
{"help"},
{"size_hor"},
{"size_ver"},
{"wait-01", "wait-02", "wait-03", "wait-04", "wait-05", "wait-06", "wait-07", "wait-08", "wait-09", "wait-10", "wait-11", "wait-12", "wait-13", "wait-14", "wait-15", "wait-16", "wait-17", "wait-18", "wait-19", "wait-20", "wait-21", "wait-22", "wait-23"},
{"text"},
{"center_ptr"},
}
//go:embed install.inf.template
var installInf string
func svgFile(theme string, name string) string {
return filepath.Join("./breeze/cursors/", theme, "src/svg/", name+".svg")
}
func imageDir(theme string) string {
return filepath.Join(imageOutputDir, strings.ToLower(theme))
}
func imageFile(theme string, name string) string {
return filepath.Join(imageDir(theme), name+".png")
}
func cursorDir(theme string) string {
return filepath.Join(cursorOutputDir, strings.ToLower(theme))
}
func inkscapeExportCmd(input string, output string, size int) *exec.Cmd {
return exec.Command(
"inkscape",
"--export-background-opacity=0",
"--export-type=png",
fmt.Sprintf("--export-width=%d", size),
fmt.Sprintf("--export-filename=%s", output),
input,
)
}
func clickGenCmd(outputDir string, hotspotX int, hotspotY int, delay int, files ...string) *exec.Cmd {
args := []string{"-o", outputDir,
"-p", "windows",
"-s", "32", "64", "96", "128", "256",
"-x", strconv.Itoa(hotspotX),
"-y", strconv.Itoa(hotspotY),
}
if delay > 0 {
args = append(args, "-d", strconv.Itoa(delay))
}
args = append(args, files...)
return exec.Command(
"clickgen",
args...,
)
}
func inkscapeExportHotSpotCoordinates(input string) (x float64, y float64, err error) {
out, err := exec.Command(
"inkscape",
"--query-id=hotspot",
"--query-x",
"--query-y",
input,
).Output()
if err != nil {
return 0, 0, err
}
idcs := strings.Split(strings.Trim(string(out), "\n"), "\n")
if len(idcs) != 2 {
return 0, 0, errors.New("inkscape output is invalid")
}
x, err = strconv.ParseFloat(idcs[0], 64)
if err != nil {
return 0, 0, errors.New("invalid first value")
}
y, err = strconv.ParseFloat(idcs[1], 64)
if err != nil {
return 0, 0, errors.New("invalid second value")
}
return x, y, nil
}
// From KDE hotspot_test
// Displace the hotspot to the right and down by 1/100th of a pixel, then
// floor. So if by some float error the hotspot is at 4.995, it will be
// displaced to 5.005, then floored to 5. This is to prevent the hotspot
// from potential off-by-one errors when the cursor is scaled.
func hotspotPx(x float64, y float64, scale float64) (int, int) {
const hotspotDisplace = 1
return int(math.Floor(((x*scale + hotspotDisplace) * 100) / 100)), int(math.Floor(((y*scale + hotspotDisplace) * 100) / 100))
}
func ExportSvg(theme string) error {
for _, items := range cursorFiles {
for _, name := range items {
fmt.Printf("Export: %s\n", name)
out, err := inkscapeExportCmd(svgFile(theme, name), imageFile(theme, name), exportSize).CombinedOutput()
if len(out) > 0 {
fmt.Println(string(out))
}
if err != nil {
fmt.Println(err)
return err
}
}
}
return nil
}
func ExportCursors(theme string) error {
for _, items := range cursorFiles {
x, y, err := inkscapeExportHotSpotCoordinates(svgFile(theme, items[0]))
if err != nil {
fmt.Println(err)
continue
}
hotspotX, hotspotY := hotspotPx(x, y, exportSize/svgBaseSize)
fmt.Printf("Cursor: %s\n", items[0])
var files []string
for _, item := range items {
files = append(files, imageFile(theme, item))
}
delay := 0
// is animation
if len(items) > 1 {
delay = animDelayMs
}
out, err := clickGenCmd(cursorDir(theme), hotspotX, hotspotY, delay, files...).CombinedOutput()
if len(out) > 0 {
fmt.Println(string(out))
}
if err != nil {
fmt.Println(err)
continue
}
}
return nil
}
func ExportInstallInf(theme string) error {
type Tmp struct {
Theme string
Version string
}
tmpl, err := template.New("install.inf").Parse(installInf)
if err != nil {
return err
}
outFile, err := os.OpenFile(filepath.Join(cursorDir(theme), "install.inf"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer outFile.Close()
fmt.Println("Generate install.inf")
return tmpl.Execute(outFile, struct {
Theme string
Version string
}{
strings.ReplaceAll(theme, "_", " "),
version},
)
}
// work around https://github.com/ful1e5/clickgen/issues/65
func fixClickgen(theme string) {
for _, files := range cursorFiles {
if len(files) != 1 {
continue
}
file := files[0]
if strings.Count(file, "-") == 1 {
origin := filepath.Join(cursorDir(theme), file[:strings.Index(file, "-")]+".cur")
os.Rename(origin, filepath.Join(cursorDir(theme), file+".cur"))
}
}
}
func main() {
for _, theme := range breezeThemes {
fmt.Println("Generate", theme)
err := os.MkdirAll(imageDir(theme), os.ModePerm)
if err != nil {
log.Fatalln(err)
}
err = os.MkdirAll(cursorDir(theme), os.ModePerm)
if err != nil {
log.Fatalln(err)
}
err = ExportSvg(theme)
if err != nil {
log.Fatalln(err)
}
err = ExportCursors(theme)
if err != nil {
log.Fatalln(err)
}
fixClickgen(theme)
err = ExportInstallInf(theme)
if err != nil {
log.Fatalln(err)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment