diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8805b35..7ea091d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -10,7 +10,8 @@ {"_type":"issue","id":"omarchy-wled-bgv","title":"Core: send color to WLED via HTTP API","description":"Send RGB color to WLED device using /json/state API endpoint. Accept IP at runtime.","status":"closed","priority":1,"issue_type":"task","assignee":"Eric Dahl","owner":"dahl.eric@gmail.com","created_at":"2026-05-01T03:23:31Z","created_by":"Eric Dahl","updated_at":"2026-05-01T15:38:06Z","started_at":"2026-05-01T03:23:41Z","closed_at":"2026-05-01T03:24:12Z","close_reason":"Closed","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/22","dependencies":[{"issue_id":"omarchy-wled-bgv","depends_on_id":"omarchy-wled-p8g","type":"blocks","created_at":"2026-04-30T23:23:34Z","created_by":"Eric Dahl","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"omarchy-wled-iqx","title":"Core: watch for theme changes and update WLED","description":"Use inotify (watchdog) to detect changes to ~/.config/omarchy/current/theme/colors.toml and push new accent color to WLED automatically.","status":"closed","priority":1,"issue_type":"feature","assignee":"Eric Dahl","owner":"dahl.eric@gmail.com","created_at":"2026-05-01T03:23:30Z","created_by":"Eric Dahl","updated_at":"2026-05-01T15:38:06Z","started_at":"2026-05-01T03:24:13Z","closed_at":"2026-05-01T03:24:17Z","close_reason":"Closed","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/23","dependencies":[{"issue_id":"omarchy-wled-iqx","depends_on_id":"omarchy-wled-bgv","type":"blocks","created_at":"2026-04-30T23:23:35Z","created_by":"Eric Dahl","metadata":"{}"},{"issue_id":"omarchy-wled-iqx","depends_on_id":"omarchy-wled-p8g","type":"blocks","created_at":"2026-04-30T23:23:35Z","created_by":"Eric Dahl","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-p8g","title":"Core: read accent color from Omarchy theme","description":"Parse accent color from ~/.config/omarchy/current/theme/colors.toml and return as RGB tuple. This is the foundation all other features depend on.","status":"closed","priority":1,"issue_type":"task","assignee":"Eric Dahl","owner":"dahl.eric@gmail.com","created_at":"2026-05-01T03:23:30Z","created_by":"Eric Dahl","updated_at":"2026-05-01T15:38:07Z","started_at":"2026-05-01T03:23:40Z","closed_at":"2026-05-01T03:24:12Z","close_reason":"Closed","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/24","dependency_count":0,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-jip","title":"tui: fix misleading RGB variable names in View swatch","description":"In tui.go non-gradient preview branch, the third channel is named `c` but holds blue (B). Rename to `r, g, b` (or `b`) so readers do not confuse with generic color. No behavior change.","status":"closed","priority":2,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:26Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:26Z","closed_at":"2026-05-04T02:35:26Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-2yf","title":"Gradient row selection (issue #60)","description":"CLI/TUI/config gradient_row 0-100; wallpaper strip from horizontal row","status":"closed","priority":2,"issue_type":"feature","assignee":"Eric Dahl","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T03:05:42Z","created_by":"Eric Dahl","updated_at":"2026-05-04T03:08:17Z","started_at":"2026-05-04T03:05:58Z","closed_at":"2026-05-04T03:08:17Z","close_reason":"Implemented gradient_row / -gradient-row (0–100), TUI slider, single-scanline strip (replaces full-height column average).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-jip","title":"tui: fix misleading RGB variable names in View swatch","description":"In tui.go non-gradient preview branch, the third channel is named `c` but holds blue (B). Rename to `r, g, b` (or `b`) so readers do not confuse with generic color. No behavior change.","status":"closed","priority":2,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:26Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:02Z","closed_at":"2026-05-04T02:35:26Z","close_reason":"Merged via PRs #46–#49 (readability pass).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/51","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-5a1","title":"Revisit: Python TUI vs Go core duplication","description":"ADR-0005 keeps Go as core; Python TUI may duplicate colour logic. Options: document intentional drift boundary or have TUI delegate reads to Go binary.","notes":"Direction (2026-05): long-term plan is to port the TUI to a Go-compatible stack so colour/WLED logic stays in one language; short-term drift vs delegate-to-binary is lower priority until that port is scheduled.","status":"closed","priority":2,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-03T04:01:32Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:24:20Z","closed_at":"2026-05-03T04:37:38Z","close_reason":"Go TUI shipped as omarchy-wled tui; Python deprecation is follow-up (R1 docs, R2 remove)","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/39","labels":["architecture"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-3fq","title":"Deepen: Sentinel/poll fallback adapter","description":"Align with ADR-0003: explicit adapter for poll path + sentinel callable so new ColorSources cannot silently omit poll support.","status":"closed","priority":2,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-03T04:01:31Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:40:48Z","closed_at":"2026-05-04T02:40:48Z","close_reason":"PollFallback + PreparePushColors/DeliverPreparedColors (ADR-0007).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/40","labels":["architecture"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-u66","title":"Revisit: split main.go into packages","description":"Conflicts CONTEXT.md single-file layout and ADR-0001 spirit for small tools. Proceed only if navigation friction outweighs ADR-0005 simplicity; update CONTEXT + ADR if split happens.","status":"closed","priority":2,"issue_type":"decision","owner":"dahl.eric@gmail.com","created_at":"2026-05-03T04:01:31Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:24:21Z","closed_at":"2026-05-03T05:01:21Z","close_reason":"Go core split into internal/paths, color, config, wallpaper, wled, source, daemon; ADR-0006 + CONTEXT/AGENTS updated. main.go is CLI wiring only.","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/41","labels":["architecture"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -32,13 +33,13 @@ {"_type":"issue","id":"omarchy-wled-1777649880013-3-2a984396","title":"refactor: remove shallow read_accent_color / read_foreground_color wrappers","description":"## Problem\n\n`read_accent_color` and `read_foreground_color` (L76–83) are thin wrappers over `_read_color_key`. They're only referenced in tests and imply a stable public API that doesn't exist. Deletion test passes — removing them concentrates no complexity.\n\n## Fix\n\nRemove both functions. Tests should use `ThemeColorSource` or `_read_color_key` directly.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-05-01T15:03:51Z","updated_at":"2026-05-01T15:26:38Z","closed_at":"2026-05-01T15:26:39Z","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/6","source_system":"github:https://github.com/ericdahl-dev/omarchy-wled/issues/6:6","metadata":null,"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-1777649879832-2-e2c09327","title":"bug: watch() ignores source.watch_path() — BgColorSource never triggers","description":"## Problem\n\n`watch()` hardcodes `THEME_NAME_FILE.parent` as the watched directory regardless of the active source. `BgColorSource` (and any future source) never fires because the observer watches the wrong directory.\n\n```python\n# L224 — always watches theme dir\nobserver.schedule(Handler(), str(THEME_NAME_FILE.parent), recursive=False)\n# should be:\nobserver.schedule(Handler(), str(source.watch_path().parent), recursive=False)\n```\n\nThe `watch_path()` seam exists on the `ColorSource` protocol precisely for this — it's just unused.\n\n## Fix\n\nReplace the hardcoded path with `source.watch_path().parent`.","status":"closed","priority":2,"issue_type":"bug","assignee":"Copilot","created_at":"2026-05-01T15:03:46Z","updated_at":"2026-05-01T15:20:53Z","closed_at":"2026-05-01T15:20:54Z","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/5","source_system":"github:https://github.com/ericdahl-dev/omarchy-wled/issues/5:5","metadata":null,"labels":["bug"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-1777649879686-1-13dd76b4","title":"AUR release","description":"Can we get AUR releases happening from GitHub via tags? Research how, implement and make a PR","status":"closed","priority":2,"issue_type":"task","assignee":"Copilot","created_at":"2026-05-01T11:32:17Z","updated_at":"2026-05-01T14:22:02Z","closed_at":"2026-05-01T14:22:03Z","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/2","source_system":"github:https://github.com/ericdahl-dev/omarchy-wled/issues/2:2","metadata":null,"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-6i0","title":"source: clarify Source interface methods for polling vs fsnotify","description":"Expand package or interface doc comments for Sentinel() (poll fingerprint string) and IsTrigger(path) (whether this path should trigger a color refresh). Optional follow-up: consider renaming to ChangeFingerprint / PathTriggersRefresh if willing to touch the interface.","status":"closed","priority":3,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:29Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:27Z","closed_at":"2026-05-04T02:35:27Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-507","title":"tui: document or rename preview debounce fields (previewGen/previewArm)","description":"tuiModel uses previewGen and previewArm for debounced preview scheduling. Add a short comment block above the fields explaining the generation/token pattern, or rename to clearer names (e.g. previewRequestSeq / previewAcceptedSeq).","status":"closed","priority":3,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:28Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:26Z","closed_at":"2026-05-04T02:35:26Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-t7t","title":"tui: replace magic index 2 for wallpaper source with named constant","description":"tui.go uses `sourceIx == 2` throughout for wallpaper/Wallpaper avg. Introduce a named constant (e.g. sourceIndexWallpaper) next to sourceMaxIdx/sourceLabels so intent is obvious without counting UI rows.","status":"closed","priority":3,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:28Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:26Z","closed_at":"2026-05-04T02:35:26Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-6i0","title":"source: clarify Source interface methods for polling vs fsnotify","description":"Expand package or interface doc comments for Sentinel() (poll fingerprint string) and IsTrigger(path) (whether this path should trigger a color refresh). Optional follow-up: consider renaming to ChangeFingerprint / PathTriggersRefresh if willing to touch the interface.","status":"closed","priority":3,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:29Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:11Z","closed_at":"2026-05-04T02:35:27Z","close_reason":"Merged via PRs #46–#49 (readability pass).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/52","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-507","title":"tui: document or rename preview debounce fields (previewGen/previewArm)","description":"tuiModel uses previewGen and previewArm for debounced preview scheduling. Add a short comment block above the fields explaining the generation/token pattern, or rename to clearer names (e.g. previewRequestSeq / previewAcceptedSeq).","status":"closed","priority":3,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:28Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:11Z","closed_at":"2026-05-04T02:35:26Z","close_reason":"Merged via PRs #46–#49 (readability pass).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/53","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-t7t","title":"tui: replace magic index 2 for wallpaper source with named constant","description":"tui.go uses `sourceIx == 2` throughout for wallpaper/Wallpaper avg. Introduce a named constant (e.g. sourceIndexWallpaper) next to sourceMaxIdx/sourceLabels so intent is obvious without counting UI rows.","status":"closed","priority":3,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:28Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:12Z","closed_at":"2026-05-04T02:35:26Z","close_reason":"Merged via PRs #46–#49 (readability pass).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/54","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-8pr","title":"refactor: extract Handler class from watch() closure for testability","description":"Handler is embedded inside watch() as a closure. Cannot be instantiated in tests. _maybe_push behavior (is_trigger, sleep, push_if_changed) is untested. Extract as top-level class taking source/state/ip/brightness/saturation.","status":"closed","priority":3,"issue_type":"task","assignee":"Eric Dahl","owner":"dahl.eric@gmail.com","created_at":"2026-05-02T20:15:30Z","created_by":"Eric Dahl","updated_at":"2026-05-02T21:25:25Z","started_at":"2026-05-02T20:18:06Z","closed_at":"2026-05-02T20:22:56Z","close_reason":"Closed","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/32","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"omarchy-wled-l8w","title":"refactor: debounce WLED HTTP sends on brightness/saturation keystrokes","description":"Every keystroke in brightness/saturation fields fires a blocking HTTP POST to WLED via _refresh_color_preview. No debounce means typing '128' sends 3 requests. Add ~300ms debounce.","status":"closed","priority":3,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-02T20:15:29Z","created_by":"Eric Dahl","updated_at":"2026-05-02T21:26:24Z","started_at":"2026-05-02T20:18:06Z","closed_at":"2026-05-02T21:26:24Z","close_reason":"Closed","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/33","metadata":null,"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-2fk","title":"docs: extend CONTEXT.md with Omarchy paths and WLED/sentinel glossary","description":"Per readability review: add short glossary entries to CONTEXT.md for Omarchy paths (colors.toml, theme.name, background symlink), WLED concepts (seg.i spatial gradient, bri), and watcher vs Sentinel polling so code can stay terse without losing new readers. Only if CONTEXT.md already exists and project wants narrative docs.","status":"closed","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:37Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:27Z","closed_at":"2026-05-04T02:35:27Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-4np","title":"color: unpack regex submatches in NamedHexFromColorsToml","description":"In internal/color/color.go loop over FindAllStringSubmatch, replace opaque m[1]/m[2] with named locals (e.g. key, hexDigits) for scanability. No logic change.","status":"closed","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:32Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:28Z","closed_at":"2026-05-04T02:35:28Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-zhj","title":"daemon/wled: internal naming polish for readability","description":"Small renames/comments: daemon PushCurrentColorIfChanged printf vars `a, z` -\u003e strip endpoints (e.g. firstRGB/lastRGB); gradientSent struct -\u003e clearer last-gradient cache name; wled jsonGETURL / seg.i `iArr` add one-line comment that payload follows WLED seg.i (offset + hex per LED). Keep behavior identical.","status":"closed","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:31Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:28Z","closed_at":"2026-05-04T02:35:28Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"omarchy-wled-94c","title":"tests: align main_test.go section headers with current API names","description":"main_test.go section comments still reference legacy labels (readColorKey, applySaturation, sendColorToWLED, makeSource). Update headers to match actual symbols (NamedHexFromColorsToml, ScaleSaturation, PostSolidJSON, FromFlag, etc.) for grep/nav consistency.","status":"closed","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:30Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:35:28Z","closed_at":"2026-05-04T02:35:28Z","close_reason":"Merged via PRs #46–#49 (readability pass).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-2fk","title":"docs: extend CONTEXT.md with Omarchy paths and WLED/sentinel glossary","description":"Per readability review: add short glossary entries to CONTEXT.md for Omarchy paths (colors.toml, theme.name, background symlink), WLED concepts (seg.i spatial gradient, bri), and watcher vs Sentinel polling so code can stay terse without losing new readers. Only if CONTEXT.md already exists and project wants narrative docs.","status":"closed","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:37Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:12Z","closed_at":"2026-05-04T02:35:27Z","close_reason":"Merged via PRs #46–#49 (readability pass).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/55","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-4np","title":"color: unpack regex submatches in NamedHexFromColorsToml","description":"In internal/color/color.go loop over FindAllStringSubmatch, replace opaque m[1]/m[2] with named locals (e.g. key, hexDigits) for scanability. No logic change.","status":"closed","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:32Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:13Z","closed_at":"2026-05-04T02:35:28Z","close_reason":"Merged via PRs #46–#49 (readability pass).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/56","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-zhj","title":"daemon/wled: internal naming polish for readability","description":"Small renames/comments: daemon PushCurrentColorIfChanged printf vars `a, z` -\u003e strip endpoints (e.g. firstRGB/lastRGB); gradientSent struct -\u003e clearer last-gradient cache name; wled jsonGETURL / seg.i `iArr` add one-line comment that payload follows WLED seg.i (offset + hex per LED). Keep behavior identical.","status":"open","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:31Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:18Z","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/57","metadata":null,"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"omarchy-wled-94c","title":"tests: align main_test.go section headers with current API names","description":"main_test.go section comments still reference legacy labels (readColorKey, applySaturation, sendColorToWLED, makeSource). Update headers to match actual symbols (NamedHexFromColorsToml, ScaleSaturation, PostSolidJSON, FromFlag, etc.) for grep/nav consistency.","status":"closed","priority":4,"issue_type":"task","owner":"dahl.eric@gmail.com","created_at":"2026-05-04T02:28:30Z","created_by":"Eric Dahl","updated_at":"2026-05-04T02:45:14Z","closed_at":"2026-05-04T02:35:28Z","close_reason":"Merged via PRs #46–#49 (readability pass).","external_ref":"https://github.com/ericdahl-dev/omarchy-wled/issues/58","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"memory","key":"always-write-code-that-is-self-documenting-a","value":"always write code that is self documenting. a human should be able to understand the code by reading it."} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a7be3ae..28d8a63 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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 { diff --git a/internal/daemon/push.go b/internal/daemon/push.go index 004727e..91bc9f5 100644 --- a/internal/daemon/push.go +++ b/internal/daemon/push.go @@ -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 diff --git a/internal/wallpaper/wallpaper.go b/internal/wallpaper/wallpaper.go index e7f9cb8..7ed1c13 100644 --- a/internal/wallpaper/wallpaper.go +++ b/internal/wallpaper/wallpaper.go @@ -4,7 +4,6 @@ package wallpaper import ( "fmt" "image" - "image/color" "image/draw" _ "image/jpeg" _ "image/png" @@ -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") } @@ -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 { diff --git a/main.go b/main.go index 9250569..f634589 100644 --- a/main.go +++ b/main.go @@ -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. @@ -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) @@ -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") @@ -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 { @@ -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 } @@ -161,6 +174,7 @@ func main() { pushOpts := daemon.PushOptions{ WallpaperGradientStrip: opts.bgGradient, GradientLEDCountOrZero: opts.gradientLEDs, + GradientRowPercent: opts.gradientRow, } if opts.once { diff --git a/main_test.go b/main_test.go index 7065530..e253944 100644 --- a/main_test.go +++ b/main_test.go @@ -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 { @@ -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) } @@ -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)) @@ -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) @@ -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") + } } diff --git a/tui.go b/tui.go index d3ccffa..bf8ebe5 100644 --- a/tui.go +++ b/tui.go @@ -53,6 +53,7 @@ const ( focusIP tuiFocus = iota focusSource focusGradient // wallpaper spatial strip (only when source = Wallpaper) + focusGradientRow focusBrightness focusSaturation focusService @@ -105,9 +106,10 @@ type tuiModel struct { brightPct int // 0–100 step 2 satPct int // 0–200 step 2 → saturation = satPct/100 - cfg tuiConfig - serviceOn bool - gradientOn bool // wallpaper-only; persisted as cfg.Gradient when source is bg + cfg tuiConfig + serviceOn bool + gradientOn bool // wallpaper-only; persisted as cfg.Gradient when source is bg + gradientRowPct int // 0–100 for wallpaper gradient scanline previewTracker *daemon.DedupeTracker previewRGB [3]uint8 @@ -144,17 +146,25 @@ func newTuiModel(initial *tuiConfig) *tuiModel { mt.Focus() grad := initial.Gradient && sourceIdxFromName(initial.Source) == sourceIndexWallpaper + gr := initial.GradientRow + if gr < 0 { + gr = 0 + } + if gr > 100 { + gr = 100 + } m := &tuiModel{ - screen: tuiScreenMain, - mainTI: mt, - focus: focusIP, - sourceIx: sourceIdxFromName(initial.Source), - brightPct: brightness255ToPct(initial.Brightness), - satPct: int(initial.Saturation * 100), - cfg: *initial, - gradientOn: grad, - previewTracker: &daemon.DedupeTracker{}, - width: 80, + screen: tuiScreenMain, + mainTI: mt, + focus: focusIP, + sourceIx: sourceIdxFromName(initial.Source), + brightPct: brightness255ToPct(initial.Brightness), + satPct: int(initial.Saturation * 100), + cfg: *initial, + gradientOn: grad, + gradientRowPct: gr, + previewTracker: &daemon.DedupeTracker{}, + width: 80, } if m.satPct > 200 { m.satPct = 200 @@ -247,7 +257,7 @@ func (m *tuiModel) updateSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.errSetup = "" m.cfg = tuiConfig{ IP: ip, Source: tuiDefaultSource, Brightness: tuiDefaultBrightness, Saturation: tuiDefaultSaturation, - Gradient: false, GradientLEDs: 0, + Gradient: false, GradientLEDs: 0, GradientRow: 50, } if err := saveTuiConfig(paths.ConfigPath, &m.cfg); err != nil { m.errSetup = err.Error() @@ -262,6 +272,7 @@ func (m *tuiModel) updateSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focus = focusIP m.sourceIx = 0 m.gradientOn = false + m.gradientRowPct = 50 m.brightPct = 100 m.satPct = int(tuiDefaultSaturation * 100) m.previewTracker = &daemon.DedupeTracker{} @@ -315,6 +326,9 @@ func (m *tuiModel) updateMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if m.sourceIx != sourceIndexWallpaper { m.gradientOn = false + if m.focus == focusGradient || m.focus == focusGradientRow { + m.focus = focusSource + } } m.resetPreviewTracker() m.ensureValidFocus() @@ -326,6 +340,9 @@ func (m *tuiModel) updateMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if m.sourceIx != sourceIndexWallpaper { m.gradientOn = false + if m.focus == focusGradient || m.focus == focusGradientRow { + m.focus = focusSource + } } m.resetPreviewTracker() m.ensureValidFocus() @@ -334,9 +351,27 @@ func (m *tuiModel) updateMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case focusGradient: if k == " " { m.gradientOn = !m.gradientOn + if !m.gradientOn && m.focus == focusGradientRow { + m.focus = focusGradient + } m.resetPreviewTracker() return m, m.schedulePreview() } + case focusGradientRow: + switch k { + case "left", "down", "h": + m.gradientRowPct-- + if m.gradientRowPct < 0 { + m.gradientRowPct = 0 + } + return m, m.schedulePreview() + case "right", "up", "l": + m.gradientRowPct++ + if m.gradientRowPct > 100 { + m.gradientRowPct = 100 + } + return m, m.schedulePreview() + } case focusBrightness: switch k { case "left", "down", "h": @@ -388,6 +423,9 @@ func (m *tuiModel) focusSequence() []tuiFocus { seq := []tuiFocus{focusIP, focusSource} if m.sourceIx == sourceIndexWallpaper { seq = append(seq, focusGradient) + if m.gradientOn { + seq = append(seq, focusGradientRow) + } } seq = append(seq, focusBrightness, focusSaturation, focusService, focusSave, focusQuit) return seq @@ -410,6 +448,9 @@ func (m *tuiModel) ensureValidFocus() { if m.focus == focusGradient && m.sourceIx != sourceIndexWallpaper { m.focus = focusSource } + if m.focus == focusGradientRow && (m.sourceIx != sourceIndexWallpaper || !m.gradientOn) { + m.focus = focusGradient + } } func (m *tuiModel) currentConfigFromForm() tuiConfig { @@ -422,6 +463,7 @@ func (m *tuiModel) currentConfigFromForm() tuiConfig { Saturation: float64(m.satPct) / 100.0, Gradient: grad, GradientLEDs: m.cfg.GradientLEDs, + GradientRow: m.gradientRowPct, } } @@ -458,7 +500,7 @@ func (m *tuiModel) updatePreviewSwatches(cfg *tuiConfig) { if err != nil { return } - strip, err := wallpaper.ColumnStripForLEDCount(wallpaper.CurrentSymlink(), n) + strip, err := wallpaper.ColumnStripForLEDCount(wallpaper.CurrentSymlink(), n, cfg.GradientRow) if err != nil || len(strip) == 0 { return } @@ -573,6 +615,13 @@ func (m *tuiModel) View() string { glabel = "On (left→right strip)" } b.WriteString(m.renderLabeled("Wallpaper gradient", glabel+" (Space)", m.focus == focusGradient) + "\n") + if m.gradientOn { + b.WriteString(m.renderLabeled( + "Gradient row (0=top·100=bottom)", + fmt.Sprintf("%d%% %s", m.gradientRowPct, m.renderBar(m.gradientRowPct, 100)), + m.focus == focusGradientRow, + ) + "\n") + } } b.WriteString(m.renderLabeled(fmt.Sprintf("Brightness: %d%%", m.brightPct), m.renderBar(m.brightPct, 100), m.focus == focusBrightness) + "\n") b.WriteString(m.renderLabeled(fmt.Sprintf("Saturation: %.2f×", float64(m.satPct)/100.0), m.renderBar(m.satPct, 200), m.focus == focusSaturation) + "\n\n") diff --git a/tui_config.go b/tui_config.go index f3bbd3f..e5ff771 100644 --- a/tui_config.go +++ b/tui_config.go @@ -23,6 +23,7 @@ type tuiConfig struct { Saturation float64 Gradient bool // wallpaper column strip (requires source bg) GradientLEDs int // 0 = fetch LED count from WLED /json/info + GradientRow int // 0–100 scanline for gradient (0=top, 100=bottom) } const ( @@ -62,6 +63,9 @@ func (c *tuiConfig) Validate() error { if c.GradientLEDs < 0 { return fmt.Errorf("gradient_leds must be >= 0 — got %d", c.GradientLEDs) } + if c.GradientRow < 0 || c.GradientRow > 100 { + return fmt.Errorf("gradient_row must be 0–100 — got %d", c.GradientRow) + } return nil } @@ -128,6 +132,12 @@ func loadTuiConfig(path string) (*tuiConfig, error) { gradLEDs = n } } + gradRow := 50 + if v, ok := m["gradient_row"]; ok { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + gradRow = n + } + } nsrc := normalizeSource(src) if nsrc != "bg" { grad = false @@ -139,6 +149,7 @@ func loadTuiConfig(path string) (*tuiConfig, error) { Saturation: sat, Gradient: grad, GradientLEDs: gradLEDs, + GradientRow: gradRow, }, nil } @@ -156,8 +167,9 @@ brightness = %d saturation = %g gradient = %t gradient_leds = %d +gradient_row = %d `, - c.IP, src, c.Brightness, c.Saturation, grad, c.GradientLEDs, + c.IP, src, c.Brightness, c.Saturation, grad, c.GradientLEDs, c.GradientRow, ) return os.WriteFile(path, []byte(content), 0o644) } @@ -169,6 +181,7 @@ func previewPushTUI(cfg *tuiConfig, tracker *daemon.DedupeTracker) error { opts := daemon.PushOptions{ WallpaperGradientStrip: cfg.Gradient && normalizeSource(cfg.Source) == "bg", GradientLEDCountOrZero: cfg.GradientLEDs, + GradientRowPercent: cfg.GradientRow, } prep, skip, err := daemon.PreparePushColors(src, cfg.IP, cfg.Saturation, opts, true, true) if err != nil { diff --git a/tui_config_test.go b/tui_config_test.go index 4592a36..3722284 100644 --- a/tui_config_test.go +++ b/tui_config_test.go @@ -57,7 +57,7 @@ func TestTuiConfigValidation(t *testing.T) { func TestTuiConfigGradientRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") - cfg := &tuiConfig{IP: "10.0.0.2", Source: "bg", Brightness: 200, Saturation: 1.1, Gradient: true, GradientLEDs: 0} + cfg := &tuiConfig{IP: "10.0.0.2", Source: "bg", Brightness: 200, Saturation: 1.1, Gradient: true, GradientLEDs: 0, GradientRow: 33} if err := saveTuiConfig(path, cfg); err != nil { t.Fatal(err) } @@ -65,11 +65,23 @@ func TestTuiConfigGradientRoundTrip(t *testing.T) { if err != nil || got == nil { t.Fatalf("load: %v %+v", err, got) } - if !got.Gradient || got.Source != "bg" || got.GradientLEDs != 0 || got.Brightness != 200 { + if !got.Gradient || got.Source != "bg" || got.GradientLEDs != 0 || got.Brightness != 200 || got.GradientRow != 33 { t.Fatalf("got %+v", got) } } +func TestTuiConfigGradientRowValidation(t *testing.T) { + base := tuiConfig{IP: "1", Source: "bg", Brightness: 255, Saturation: 1.0, Gradient: true, GradientLEDs: 0, GradientRow: 50} + if err := (&base).Validate(); err != nil { + t.Fatal(err) + } + bad := base + bad.GradientRow = 101 + if err := (&bad).Validate(); err == nil { + t.Fatal("expected error for gradient_row > 100") + } +} + func TestNormalizeSourceRoundTrip(t *testing.T) { if g := normalizeSource("foreground"); g != "fg" { t.Errorf("got %q", g)