Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
type PushOptions struct {
WallpaperGradientStrip bool
GradientLEDCountOrZero int // 0 → query WLED /json/info
// GradientRowPercent selects which horizontal scanline feeds the strip (0 = top, 100 = bottom).
GradientRowPercent int
}

type gradientStripSnapshot struct {
Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func PreparePushColors(src source.Source, wledIP string, saturation float64, opt
if err != nil {
return PreparedColors{}, false, err
}
stripRGB, err := wallpaper.ColumnStripForLEDCount(wallpaper.CurrentSymlink(), n)
stripRGB, err := wallpaper.ColumnStripForLEDCount(wallpaper.CurrentSymlink(), n, opts.GradientRowPercent)
if err != nil {
if strictStripDecode {
return PreparedColors{}, false, err
Expand Down
54 changes: 32 additions & 22 deletions internal/wallpaper/wallpaper.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package wallpaper
import (
"fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg"
_ "image/png"
Expand Down Expand Up @@ -131,34 +130,44 @@ func LeftRightHalvesLinearAvg(wallpaperSymlinkPath string) (left, right [3]uint8
return left, right, err
}

func columnAverageRow(rgbaFull *image.RGBA) *image.RGBA {
// RowYFromPercent maps rowPercent in [0,100] to a row index in [0, height-1].
// 0 is the top row, 100 is the bottom row.
func RowYFromPercent(height, rowPercent int) int {
if height <= 1 {
return 0
}
if rowPercent < 0 {
rowPercent = 0
}
if rowPercent > 100 {
rowPercent = 100
}
return int(math.Round(float64(rowPercent) / 100.0 * float64(height-1)))
}

func horizontalScanlineRGBA(rgbaFull *image.RGBA, rowIndex int) *image.RGBA {
b := rgbaFull.Bounds()
w, h := b.Dx(), b.Dy()
out := image.NewRGBA(image.Rect(0, 0, w, 1))
n := float64(h)
if w <= 0 || h <= 0 {
return out
}
if rowIndex < 0 {
rowIndex = 0
}
if rowIndex >= h {
rowIndex = h - 1
}
y := b.Min.Y + rowIndex
for x := 0; x < w; x++ {
var sumR, sumG, sumB float64
for y := 0; y < h; y++ {
c := rgbaFull.RGBAAt(b.Min.X+x, b.Min.Y+y)
sumR += float64(wallpaperSrgbToLinearByte[c.R])
sumG += float64(wallpaperSrgbToLinearByte[c.G])
sumB += float64(wallpaperSrgbToLinearByte[c.B])
}
lr := uint8(math.Round(sumR / n))
lg := uint8(math.Round(sumG / n))
lb := uint8(math.Round(sumB / n))
out.Set(x, 0, color.RGBA{
R: wallpaperLinearToSrgbByte[lr],
G: wallpaperLinearToSrgbByte[lg],
B: wallpaperLinearToSrgbByte[lb],
A: 255,
})
out.Set(x, 0, rgbaFull.RGBAAt(b.Min.X+x, y))
}
return out
}

// ColumnStripForLEDCount builds one sample per wallpaper column, then resamples to ledCount LEDs.
func ColumnStripForLEDCount(wallpaperSymlinkPath string, ledCount int) ([][3]uint8, error) {
// ColumnStripForLEDCount builds one sample per wallpaper column at the horizontal row rowPercent,
// then resamples to ledCount LEDs. rowPercent 0 = top, 100 = bottom.
func ColumnStripForLEDCount(wallpaperSymlinkPath string, ledCount int, rowPercent int) ([][3]uint8, error) {
if ledCount <= 0 {
return nil, fmt.Errorf("ledCount must be positive")
}
Expand All @@ -181,7 +190,8 @@ func ColumnStripForLEDCount(wallpaperSymlinkPath string, ledCount int) ([][3]uin
}
rgbaFull := image.NewRGBA(bounds)
draw.Draw(rgbaFull, bounds, decoded, bounds.Min, draw.Src)
rowRgba := columnAverageRow(rgbaFull)
y := RowYFromPercent(bounds.Dy(), rowPercent)
rowRgba := horizontalScanlineRGBA(rgbaFull, y)

pix := rowRgba.Pix
for i := 0; i < len(pix); i += 4 {
Expand Down
16 changes: 15 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type cliOpts struct {
wledIP string
bgGradient bool
gradientLEDs int
gradientRow int // 0–100; horizontal scanline for wallpaper gradient strip
}

// parseArgs parses argv using the same defaults as loadConfig merge rules.
Expand Down Expand Up @@ -59,6 +60,12 @@ func parseArgs(args []string, cfg map[string]string, output io.Writer) (*cliOpts
defaultGradientLEDs = n
}
}
defaultGradientRow := 50
if v, ok := cfg["gradient_row"]; ok {
if n, err := strconv.Atoi(v); err == nil {
defaultGradientRow = n
}
}

fs := flag.NewFlagSet("omarchy-wled", flag.ContinueOnError)
fs.SetOutput(output)
Expand All @@ -75,9 +82,11 @@ func parseArgs(args []string, cfg map[string]string, output io.Writer) (*cliOpts
"Saturation multiplier (0.0=greyscale, 1.0=unchanged, >1.0=boost;\n"+
"default 1.2 for accent, 1.0 for fg/bg)")
gradient := fs.Bool("gradient", defaultGradient,
"Wallpaper left/right averages as strip endpoints (spatial fade via seg.i; requires -source bg)")
"Wallpaper horizontal scanline as per-LED strip (spatial fade via seg.i; requires -source bg)")
gradientLEDs := fs.Int("gradient-leds", defaultGradientLEDs,
"LED count for spatial fade (0 = fetch from WLED /json/info)")
gradientRow := fs.Int("gradient-row", defaultGradientRow,
"Wallpaper gradient: vertical position of scanline 0–100 (0=top, 100=bottom; default 50)")
once := fs.Bool("once", false, "Send current color once and exit (no watching)")
fs.Usage = func() {
fmt.Fprintf(output, "Usage: omarchy-wled [options] [WLED_IP]\n")
Expand All @@ -96,6 +105,7 @@ func parseArgs(args []string, cfg map[string]string, output io.Writer) (*cliOpts
opts.once = *once
opts.bgGradient = *gradient
opts.gradientLEDs = *gradientLEDs
opts.gradientRow = *gradientRow

opts.wledIP = cfg["ip"]
if fs.NArg() > 0 {
Expand All @@ -108,6 +118,9 @@ func validateCli(opts *cliOpts) error {
if opts.bgGradient && opts.sourceName != "bg" {
return fmt.Errorf("-gradient requires -source bg")
}
if opts.gradientRow < 0 || opts.gradientRow > 100 {
return fmt.Errorf("-gradient-row must be 0–100 — got %d", opts.gradientRow)
}
return nil
}

Expand Down Expand Up @@ -161,6 +174,7 @@ func main() {
pushOpts := daemon.PushOptions{
WallpaperGradientStrip: opts.bgGradient,
GradientLEDCountOrZero: opts.gradientLEDs,
GradientRowPercent: opts.gradientRow,
}

if opts.once {
Expand Down
58 changes: 56 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ func TestPushIfChangedGradientDedupes(t *testing.T) {

tracker := &daemon.DedupeTracker{}
src := &source.WallpaperAverage{}
opts := daemon.PushOptions{WallpaperGradientStrip: true, GradientLEDCountOrZero: 4}
opts := daemon.PushOptions{WallpaperGradientStrip: true, GradientLEDCountOrZero: 4, GradientRowPercent: 50}
daemon.PushCurrentColorIfChanged(src, tracker, "ignored", 255, 1.0, opts)
daemon.PushCurrentColorIfChanged(src, tracker, "ignored", 255, 1.0, opts)
if sent != 1 {
Expand Down Expand Up @@ -460,7 +460,7 @@ func TestWallpaperColumnAverageMapsToLEDs(t *testing.T) {
if err := os.Symlink(imgPath, link); err != nil {
t.Fatal(err)
}
colors, err := wallpaper.ColumnStripForLEDCount(link, 3)
colors, err := wallpaper.ColumnStripForLEDCount(link, 3, 50)
if err != nil {
t.Fatal(err)
}
Expand All @@ -475,6 +475,47 @@ func TestWallpaperColumnAverageMapsToLEDs(t *testing.T) {
}
}

// Two-row image: top row red-channel gradient; bottom row blue-channel gradient — row choice changes strip.
func TestWallpaperGradientScanlineDiffersByRow(t *testing.T) {
dir := t.TempDir()
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
for x := 0; x < 3; x++ {
img.Set(x, 0, imgcolor.RGBA{R: uint8(50 + x*80), A: 255})
img.Set(x, 1, imgcolor.RGBA{B: uint8(50 + x*80), A: 255})
}
imgPath := filepath.Join(dir, "rows.png")
f, err := os.Create(imgPath)
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
t.Fatal(err)
}
f.Close()
link := filepath.Join(dir, "background")
if err := os.Symlink(imgPath, link); err != nil {
t.Fatal(err)
}
top, err := wallpaper.ColumnStripForLEDCount(link, 3, 0)
if err != nil {
t.Fatal(err)
}
bot, err := wallpaper.ColumnStripForLEDCount(link, 3, 100)
if err != nil {
t.Fatal(err)
}
if top[0] == bot[0] && top[2] == bot[2] {
t.Fatalf("top vs bottom row strips too similar: top[0]=%v bot[0]=%v", top[0], bot[0])
}
if top[0][0] <= top[0][2] {
t.Errorf("top row strip should be red-heavy at left: %v", top[0])
}
if bot[0][2] <= bot[0][0] {
t.Errorf("bottom row strip should be blue-heavy at left: %v", bot[0])
}
}

func TestReadBgGradientHorizontalHalves(t *testing.T) {
dir := t.TempDir()
img := image.NewRGBA(image.Rect(0, 0, 4, 2))
Expand Down Expand Up @@ -658,6 +699,7 @@ func TestParseArgsGradientFromConfig(t *testing.T) {
opts, err := parseArgs([]string{}, map[string]string{
"gradient": "true",
"gradient_leds": "120",
"gradient_row": "25",
}, io.Discard)
if err != nil {
t.Fatal(err)
Expand All @@ -668,4 +710,16 @@ func TestParseArgsGradientFromConfig(t *testing.T) {
if opts.gradientLEDs != 120 {
t.Fatalf("gradientLEDs: got %d want 120", opts.gradientLEDs)
}
if opts.gradientRow != 25 {
t.Fatalf("gradientRow: got %d want 25", opts.gradientRow)
}
}

func TestValidateCliGradientRowRange(t *testing.T) {
if err := validateCli(&cliOpts{gradientRow: 101}); err == nil {
t.Fatal("expected error for gradient row > 100")
}
if err := validateCli(&cliOpts{gradientRow: -1}); err == nil {
t.Fatal("expected error for gradient row < 0")
}
}
Loading
Loading