Skip to content
Merged
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.

3 changes: 3 additions & 0 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import (
"time"

"github.com/ericdahl-dev/omarchy-wled/internal/source"
"github.com/ericdahl-dev/omarchy-wled/internal/wallpaper"
"github.com/fsnotify/fsnotify"
)

// PushOptions carries behavior beyond “what color”: spatial gradient strip length, etc.
type PushOptions struct {
WallpaperGradientStrip bool
GradientLEDCountOrZero int // 0 → query WLED /json/info
GradientSample wallpaper.GradientSampleKind
GradientRowPercent int // 0–100 when GradientSampleRow
}

type gradientStripSnapshot struct {
Expand Down
3 changes: 2 additions & 1 deletion internal/daemon/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ 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.GradientSample, opts.GradientRowPercent)
if err != nil {
if strictStripDecode {
return PreparedColors{}, false, err
Expand Down
70 changes: 68 additions & 2 deletions internal/wallpaper/wallpaper.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"math"
"os"
"path/filepath"
"strings"

"github.com/ericdahl-dev/omarchy-wled/internal/paths"
xdraw "golang.org/x/image/draw"
Expand Down Expand Up @@ -131,6 +132,26 @@ func LeftRightHalvesLinearAvg(wallpaperSymlinkPath string) (left, right [3]uint8
return left, right, err
}

// GradientSampleKind selects how each column is reduced before LED resampling.
type GradientSampleKind byte

const (
// GradientSampleAverage averages each column vertically over the full image (legacy behavior).
GradientSampleAverage GradientSampleKind = iota
// GradientSampleRow uses a single horizontal scanline (see RowYFromPercent).
GradientSampleRow
)

// GradientSampleKindFromString parses config/CLI values; unknown → average.
func GradientSampleKindFromString(s string) GradientSampleKind {
switch strings.TrimSpace(strings.ToLower(s)) {
case "row":
return GradientSampleRow
default:
return GradientSampleAverage
}
}

func columnAverageRow(rgbaFull *image.RGBA) *image.RGBA {
b := rgbaFull.Bounds()
w, h := b.Dx(), b.Dy()
Expand All @@ -157,8 +178,43 @@ func columnAverageRow(rgbaFull *image.RGBA) *image.RGBA {
return out
}

// RowYFromPercent maps rowPercent in [0,100] to a row index in [0, height-1] (0=top, 100=bottom).
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))
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++ {
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) {
// kind selects full-height column average vs a single row; rowPercent is used only for GradientSampleRow.
func ColumnStripForLEDCount(wallpaperSymlinkPath string, ledCount int, kind GradientSampleKind, rowPercent int) ([][3]uint8, error) {
if ledCount <= 0 {
return nil, fmt.Errorf("ledCount must be positive")
}
Expand All @@ -181,7 +237,17 @@ func ColumnStripForLEDCount(wallpaperSymlinkPath string, ledCount int) ([][3]uin
}
rgbaFull := image.NewRGBA(bounds)
draw.Draw(rgbaFull, bounds, decoded, bounds.Min, draw.Src)
rowRgba := columnAverageRow(rgbaFull)

var rowRgba *image.RGBA
switch kind {
case GradientSampleAverage:
rowRgba = columnAverageRow(rgbaFull)
case GradientSampleRow:
y := RowYFromPercent(bounds.Dy(), rowPercent)
rowRgba = horizontalScanlineRGBA(rgbaFull, y)
default:
return nil, fmt.Errorf("unsupported gradient sample kind")
}

pix := rowRgba.Pix
for i := 0; i < len(pix); i += 4 {
Expand Down
32 changes: 31 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import (
"io"
"os"
"strconv"
"strings"

"github.com/ericdahl-dev/omarchy-wled/internal/config"
"github.com/ericdahl-dev/omarchy-wled/internal/daemon"
"github.com/ericdahl-dev/omarchy-wled/internal/paths"
"github.com/ericdahl-dev/omarchy-wled/internal/source"
"github.com/ericdahl-dev/omarchy-wled/internal/wallpaper"
)

// version is set at link time: go build -ldflags "-X main.version=v1.2.3"
Expand All @@ -29,6 +31,8 @@ type cliOpts struct {
wledIP string
bgGradient bool
gradientLEDs int
gradientSample string // average | row
gradientRow int // 0–100 when row mode
}

// parseArgs parses argv using the same defaults as loadConfig merge rules.
Expand Down Expand Up @@ -59,6 +63,16 @@ func parseArgs(args []string, cfg map[string]string, output io.Writer) (*cliOpts
defaultGradientLEDs = n
}
}
defaultGradientSample := cfg["gradient_sample"]
if defaultGradientSample == "" {
defaultGradientSample = "average"
}
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 +89,13 @@ 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 per-column strip (spatial fade via seg.i; requires -source bg; see -gradient-sample)")
gradientLEDs := fs.Int("gradient-leds", defaultGradientLEDs,
"LED count for spatial fade (0 = fetch from WLED /json/info)")
gradientSample := fs.String("gradient-sample", defaultGradientSample,
"Wallpaper gradient sampling: average (full-height column avg) or row (single scanline)")
gradientRow := fs.Int("gradient-row", defaultGradientRow,
"With gradient-sample=row: vertical position 0–100 (0=top, 100=bottom)")
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 +114,8 @@ func parseArgs(args []string, cfg map[string]string, output io.Writer) (*cliOpts
opts.once = *once
opts.bgGradient = *gradient
opts.gradientLEDs = *gradientLEDs
opts.gradientSample = *gradientSample
opts.gradientRow = *gradientRow

opts.wledIP = cfg["ip"]
if fs.NArg() > 0 {
Expand All @@ -108,6 +128,14 @@ func validateCli(opts *cliOpts) error {
if opts.bgGradient && opts.sourceName != "bg" {
return fmt.Errorf("-gradient requires -source bg")
}
switch strings.TrimSpace(strings.ToLower(opts.gradientSample)) {
case "", "average", "row":
default:
return fmt.Errorf("-gradient-sample must be average or row — got %q", opts.gradientSample)
}
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 +189,8 @@ func main() {
pushOpts := daemon.PushOptions{
WallpaperGradientStrip: opts.bgGradient,
GradientLEDCountOrZero: opts.gradientLEDs,
GradientSample: wallpaper.GradientSampleKindFromString(opts.gradientSample),
GradientRowPercent: opts.gradientRow,
}

if opts.once {
Expand Down
60 changes: 57 additions & 3 deletions main_test.go
Original file line number Diff line number Diff line change
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, wallpaper.GradientSampleAverage, 0)
if err != nil {
t.Fatal(err)
}
Expand All @@ -475,6 +475,43 @@ func TestWallpaperColumnAverageMapsToLEDs(t *testing.T) {
}
}

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, wallpaper.GradientSampleRow, 0)
if err != nil {
t.Fatal(err)
}
bot, err := wallpaper.ColumnStripForLEDCount(link, 3, wallpaper.GradientSampleRow, 100)
if err != nil {
t.Fatal(err)
}
if top[0][0] <= top[0][2] {
t.Errorf("top scanline strip should be red-heavy at left: %v", top[0])
}
if bot[0][2] <= bot[0][0] {
t.Errorf("bottom scanline 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 @@ -656,8 +693,10 @@ func TestValidateCliGradientRequiresBg(t *testing.T) {

func TestParseArgsGradientFromConfig(t *testing.T) {
opts, err := parseArgs([]string{}, map[string]string{
"gradient": "true",
"gradient_leds": "120",
"gradient": "true",
"gradient_leds": "120",
"gradient_sample": "row",
"gradient_row": "33",
}, io.Discard)
if err != nil {
t.Fatal(err)
Expand All @@ -668,4 +707,19 @@ func TestParseArgsGradientFromConfig(t *testing.T) {
if opts.gradientLEDs != 120 {
t.Fatalf("gradientLEDs: got %d want 120", opts.gradientLEDs)
}
if opts.gradientSample != "row" {
t.Fatalf("gradientSample: got %q want row", opts.gradientSample)
}
if opts.gradientRow != 33 {
t.Fatalf("gradientRow: got %d want 33", opts.gradientRow)
}
}

func TestValidateCliGradientSampleAndRow(t *testing.T) {
if err := validateCli(&cliOpts{gradientSample: "bogus"}); err == nil {
t.Fatal("expected error for bad gradient-sample")
}
if err := validateCli(&cliOpts{gradientSample: "average", gradientRow: 101}); err == nil {
t.Fatal("expected error for gradient-row > 100")
}
}
Loading
Loading