diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cf7ff7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,132 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# A newer push to the same branch makes an in-flight run irrelevant. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # Unit tests need no GTK4 headers, so they run on plain ubuntu and report fast. + # PURE_PKGS is derived by the Makefile, so a newly added package is picked up + # here without touching this file. + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + # goreleaser runs `go mod tidy` as a before-hook, so an untidy tree means + # every release build silently rewrites go.sum after goreleaser's git-dirty + # check has already passed — the released binary's inputs then differ from + # the tagged tree. Nothing else here would notice: stale go.sum entries are + # ignored by the build. v1.1.6 hashes survived yesterday's api bump this way. + - name: go.mod and go.sum are tidy + run: go mod tidy -diff + - name: Unit tests + run: make test + - name: Unit tests (race detector) + run: make race + - name: Coverage summary + run: make cover + + # Compiling needs the GTK4 and layer-shell headers, so this uses the same Arch + # container as the release job. Kept separate from `test` so a logic failure is + # not hidden behind a toolchain problem. + build: + name: Build and lint + runs-on: ubuntu-latest + container: archlinux:latest + steps: + - name: Install dependencies + run: pacman -Syu --noconfirm git base-devel go gtk4 gtk4-layer-shell gobject-introspection + - uses: actions/checkout@v7 + - name: Fix git ownership + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + # cache: false because this job manages the Go caches explicitly below. + # Leaving setup-go's implicit cache on would have two mechanisms writing the + # same directories, and its key has no fallback — any go.sum change is a + # total miss, which is exactly the case that hurts most here. + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: false + + # Ask the toolchain where its caches live rather than hardcoding paths. + # Inside a container HOME is /github/home, not the runner's ~, so a guessed + # path silently caches nothing — the failure mode is a green run that is + # still slow, which is easy to miss. + - name: Locate Go caches + id: gocache + run: | + echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT" + echo "mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT" + + # This is the whole cost of the job. A cold CGO build of the gotk4 bindings + # took 707s on the last run, against ~40s for every other step combined. + # Those bindings are tens of thousands of generated cgo wrappers that change + # only when the dependency does, so they cache almost perfectly. + # + # The key pins the exact dependency set; the fallbacks widen from there, so a + # dependency bump still restores the previous cache and recompiles only what + # actually changed instead of starting from nothing. + # + # Caches are branch-scoped, with only the default branch's visible everywhere, + # so the first run after this merges still pays full price — the saving shows + # from the run after that. + # Restore and save are split rather than using the combined action, so the + # cache is written even when a later step fails. That is not hypothetical: + # the previous run spent 707s building successfully and then failed on lint, + # and a combined cache would have discarded the expensive part and paid for + # it again next time. + - name: Restore the CGO build cache + id: cgocache + uses: actions/cache/restore@v6 + with: + path: | + ${{ steps.gocache.outputs.build }} + ${{ steps.gocache.outputs.mod }} + key: cgo-${{ runner.os }}-go${{ hashFiles('go.mod') }}-${{ hashFiles('go.sum') }} + restore-keys: | + cgo-${{ runner.os }}-go${{ hashFiles('go.mod') }}- + cgo-${{ runner.os }}- + + - name: Build + run: make build + # Same target `make lint` depends on, so a formatting slip fails locally + # before it can fail here. Previously this step carried its own copy of the + # command and nothing in the Makefile checked formatting at all. + - name: gofmt + run: make fmt-check + # Pinned, not `latest`. `latest` is what broke this: action v6 only knows the + # golangci-lint v1 line, whose final release (1.64.8) was built with Go 1.24 + # and so refuses a go.mod targeting 1.25 — a failure no change to this repo + # could have caused or fixed. A pin also makes CI run the identical linter to + # `make lint`, so "passes locally" means something. Bump deliberately, in step + # with the Go version in go.mod. + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.12.2 + + # Last, and always: lint reuses the warm build cache above and adds its own + # analysis output to it, so saving after lint captures more than saving after + # build would. `always()` is what makes a failing lint still leave a usable + # cache behind; the cache-hit guard avoids the warning from rewriting a key + # that already exists. + - name: Save the CGO build cache + if: always() && steps.cgocache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: | + ${{ steps.gocache.outputs.build }} + ${{ steps.gocache.outputs.mod }} + key: ${{ steps.cgocache.outputs.cache-primary-key }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ee00a15..8c28d40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,18 +33,22 @@ jobs: steps: - name: Install dependencies run: pacman -Syu --noconfirm git base-devel go gtk4 gtk4-layer-shell gobject-introspection - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ github.event.inputs.tag || github.ref }} - name: Fix git ownership run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v7 with: go-version-file: go.mod - - uses: goreleaser/goreleaser-action@v6 + # Constrained to the v2 line rather than `latest`, so patch and minor fixes + # still arrive but a major cannot land unannounced in the middle of a + # release. `.goreleaser.yml` pins the config schema at version 2, so the two + # stay in step. Local `make snapshot` runs 2.14.0. + - uses: goreleaser/goreleaser-action@v7 with: - version: latest + version: '~> v2' args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -61,20 +65,49 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.aur && (needs.release.result == 'success' || needs.release.result == 'skipped')) ) steps: - - uses: actions/checkout@v4 + # Check out the tag being released, not the default branch. The release job + # above is explicit about this and this one was not, so a workflow_dispatch + # for an older tag would package that tag's binary with the PKGBUILD from + # main. Harmless on a tag push, where the default ref is already the tag. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.tag || github.ref }} - name: Get version, sha256, and pkgrel id: meta run: | + # pipefail is the point of this line. The default shell is `bash -e` + # without it, so a pipeline's status is its *last* command: a failed + # download still exits 0, and `sha256sum` of the resulting empty stream + # returns e3b0c442… — a perfectly well-formed checksum that is wrong for + # every file. The AUR package would then publish and fail its integrity + # check for every user, with this workflow green. + set -euo pipefail + TAG="${{ github.event.inputs.tag || github.ref_name }}" VERSION="${TAG#v}" URL="https://github.com/dahui/z13gui/releases/download/${TAG}/z13gui_${VERSION}_linux_amd64.tar.gz" SHA256=$(curl -fsSL "$URL" | sha256sum | cut -d' ' -f1) - # Compute pkgrel: increment if same version already on AUR, else 1 + # Belt and braces, and self-documenting: name the failure rather than + # leaving a future reader to recognise the empty-stream hash. + EMPTY_SHA=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + if [ "$SHA256" = "$EMPTY_SHA" ]; then + echo "::error::downloaded an empty stream from $URL — refusing to publish a bad checksum" + exit 1 + fi + case "$SHA256" in + [0-9a-f][0-9a-f]*) [ "${#SHA256}" -eq 64 ] || { echo "::error::malformed sha256: $SHA256"; exit 1; } ;; + *) echo "::error::malformed sha256: $SHA256"; exit 1 ;; + esac + + # Compute pkgrel: increment if same version already on AUR, else 1. + # This lookup is advisory — it only picks pkgrel — so unlike the download + # above it is deliberately tolerant of a blip in the AUR API, which must + # not fail a release. `|| true` keeps that behaviour now pipefail is on. PKGREL=1 AUR_VER=$(curl -fsSL "https://aur.archlinux.org/rpc/v5/info?arg[]=z13gui-bin" \ - | jq -r '.results[0].Version // empty') + | jq -r '.results[0].Version // empty' || true) if [ -n "$AUR_VER" ]; then AUR_PKGVER="${AUR_VER%-*}" AUR_PKGREL="${AUR_VER##*-}" @@ -151,13 +184,13 @@ jobs: (github.event_name == 'workflow_dispatch' && inputs.docs && (needs.release.result == 'success' || needs.release.result == 'skipped')) ) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: 3.x - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: key: mkdocs-material-${{ hashFiles('requirements.txt') }} path: .cache diff --git a/.goreleaser.yml b/.goreleaser.yml index 051f672..7ea9994 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -24,6 +24,11 @@ archives: name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" files: - LICENSE + - NOTICE + - TRADEMARK.md + # Inter is embedded into the binary with //go:embed, so the OFL notice has + # to travel with every artifact, not just sit in the source tree. + - internal/gui/fonts/LICENSE-Inter.txt - contrib/z13gui.service - contrib/z13gui.desktop - contrib/99-z13gui-gamepad.rules @@ -55,6 +60,12 @@ nfpms: contents: - src: LICENSE dst: /usr/share/licenses/z13gui/LICENSE + - src: NOTICE + dst: /usr/share/licenses/z13gui/NOTICE + # The embedded typeface ships inside the binary, so its licence belongs + # alongside the package's own. + - src: internal/gui/fonts/LICENSE-Inter.txt + dst: /usr/share/licenses/z13gui/LICENSE-Inter.txt - src: contrib/z13gui.service dst: /usr/lib/systemd/user/z13gui.service - src: contrib/z13gui.desktop diff --git a/CLAUDE.md b/CLAUDE.md index 8f48046..983904f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ It has two display backends: ## Companion project: z13ctl The `z13ctl` daemon (module `github.com/dahui/z13ctl`) is a sibling repo. -Its `api/` submodule (`github.com/dahui/z13ctl/api`) is published at tag `api/v1.1.6` +Its `api/` submodule (`github.com/dahui/z13ctl/api`) is published at tag `api/v1.1.7` on GitHub. During local development, a `go.work` file in this repo (if present, gitignored) provides @@ -35,9 +35,9 @@ internal/gui/ controls.go All GTK widget construction (drawer, views, bottom bar) tdp.go Custom profile view: TDP sliders, fan curve editor, undervolt, telemetry sync.go Daemon state sync and API send functions - color.go colorInput struct, HSL conversion, color picker view logic - focus.go 2D grid gamepad focus navigation + modal slider editing - log.go Split-level slog handler (app vs GTK noise filtering) + color.go colorInput widget + color picker view (math in internal/colorconv) + errbar.go Error bar: reportError/clearError, the only user-facing error surface + focus.go Focus widget adaptor (navigation logic in internal/focusgrid) layout.css Embedded structural CSS (touch targets, sizing) — PRIORITY_APPLICATION theme-default.css Embedded theme template with @define-color placeholders — PRIORITY_USER theme-default.toml Embedded default theme colors (rog-dark), used by --print-theme @@ -59,12 +59,22 @@ internal/gui/gamepad/hidblocker/ internal/gui/gamescope/ gamescope.go Gamescope X11 overlay backend (Steam Gaming Mode) internal/theme/ - theme.go Theme types, TOML parsing, CSS generation, config persistence - builtins.go 15 built-in themes (8 dark, 7 light) with accent variants - theme_test.go Theme parsing and CSS generation tests -internal/togglegate/ - togglegate.go Pure debounce helper for duplicate gui-toggle bursts - togglegate_test.go Unit tests (pure Go, no GTK4) + theme.go Colors struct (8 tokens), 15 built-in themes, accent variants + parse.go theme.toml parsing; starts from DefaultColors so missing keys + inherit defaults — this is what keeps old theme.toml files working + when a new color token is added + css.go @define-color generation from a Colors value + config.go Config persistence (selected theme/accent) + *_test.go Theme parsing, CSS generation, and built-in completeness tests +internal/power/ Limits value: TDP/fan bounds + rules (mirrors z13ctl internal/cli) +internal/daemon/ Err(handled, err): collapses an api result pair into one error +internal/focusgrid/ Gamepad focus navigation: row/col/section index math +internal/keyrepeat/ Tracker: which held direction owns the gamepad auto-repeat +internal/colorconv/ hex <-> HSL/RGB conversion and colour validation +internal/lighting/ RGB mode resolution, per-mode controls, defaults +internal/uiscale/ Gamescope UI scale factor (cannot live in the cgo package) +internal/startup/ CLI arg scanning + split-level slog handler +internal/togglegate/ Debounce helper for duplicate gui-toggle bursts contrib/ z13gui.service systemd user service (EnvironmentFile for gamescope-session) z13gui.desktop Desktop entry @@ -111,6 +121,97 @@ contrib/ - **State source of truth**: daemon is the source of truth. On show, `api.SendGetState()` is called and `syncState()` updates widgets. Widget signals are suppressed during sync via `Window.syncing bool`. +- **Error surface** (`errbar.go`): every daemon call reports failures through + `w.reportError(op, err)` and clears on success with `clearError()`/`clearErrorAsync()`. + The bar is appended to `outer` between `viewStack` and the bottom bar, so one instance + covers all four views in both backends. Its dismiss button is in every view's focus + grid via `errBarFocusItem()` at `errBarRow` — without that a controller cannot + dismiss an error at all. `reportError` drops the message when the drawer is already + closed, so a call still in flight at close does not leave the bar up for the next + open; the journal still has it. **Never drop a daemon error into `slog` alone** — + that is what made z13ctl issue #14 look like a dead button for weeks. `reportError` is + safe from any goroutine (it marshals via `glib.IdleAdd`) and logs internally, so call + sites should not also `slog.Warn`. +- **`handled == false` means the daemon is not running, and it is not an error.** + Every `api.Send*` returns `(handled bool, err error)`; when the socket dial fails + it returns `handled=false, err=nil`, because nothing was sent. **Never test `err` + alone** — wrap every call in `daemon.Err(api.SendX(...))`, which takes the result + pair directly so a site cannot read one and forget the other. All thirteen call + sites used to discard `handled`, so with the daemon stopped every operation took + its success path: `Save TDP` cleared the error bar, logged "custom TDP saved" and + left the typed values on screen. That is z13ctl issue #14's dead-button failure + rebuilt one layer up, and it defeated the error bar entirely. + `internal/daemon`'s contract test dials a temp `XDG_RUNTIME_DIR` to pin the api + convention rather than trusting its doc comment. + - The telemetry poll is the one deliberate exception, and says so in a comment: + it is a background poll, and reporting it every second would overwrite + whatever error the user was reading. +- **Daemon calls must not run on the GTK thread**: `api` commands carry a 10s deadline + (`commandTimeout`, api v1.1.7), so an inline call freezes the drawer for up to 10s + against a wedged daemon. Read widget values on the main thread, then do the socket + round-trip in a goroutine — see `sendApply()` in `sync.go` for the pattern. +- **Decisions live outside `internal/gui`; widgets live inside it.** `internal/gui` + needs CGO + GTK4 headers, so `make test` cannot even compile it — anything left in + there is permanently unverifiable. Every rule, calculation or classification + belongs in a pure package (`power`, `focusgrid`, `colorconv`, `lighting`, + `uiscale`, `startup`, `togglegate`); the GTK files are thin adaptors that read + widgets, call out, and apply the answer. Extracting logic this way has caught + seven real bugs so far, none of which were found by reading the code. + - `make test` derives its package list with + `go list ./internal/... | grep -v /internal/gui`, so a new pure package is + picked up automatically — nothing to remember. + - **Never read or write a GTK widget from a goroutine.** GTK is not thread-safe; + this is undefined behaviour, not a stale read. Snapshot widget values on the + main thread into plain data, then do the socket call in the goroutine — see + `readTdpRequest`/`tdpRequest.send` in `tdp.go` and `sendApply` in `sync.go`. + Come back to the main thread with `glib.IdleAdd`. + - **`Window.visible` is an `atomic.Bool`, and it is the only `Window` field any + goroutine may touch.** The gamepad reader gates every event on it, so a plain + bool there is a data race — and because `internal/gui` is excluded from + `go test -race`, nothing would ever report it. Anything else a goroutine needs + must be passed to it as plain data, not read off `Window`. +- **Ordering matters for anything a goroutine applies to the outside world.** + `show`/`hide` issue their gamepad grab and release from separate goroutines, so + the two race: a grab landing after a hide leaves every controller exclusively + grabbed with nothing on screen and no input reaching the game, and a release + landing after a re-show (easy inside the gamescope path's deliberate 200ms delay) + hands the game the same D-pad presses navigating the drawer. `Window.grabGen` is + incremented on the GTK thread and passed to `gamepad.Reader.SetGrabbed(seq, grab)`, + which drops superseded requests. Add a sequence to any similar pair. +- **Device limits are a value, not constants.** `power.Limits` holds the TDP range, + fan floor, temperature axis and stock PPT table; `Window.limits` is initialised to + `power.DefaultLimits()` (the Z13's values). **No TDP or fan bound may be hardcoded + in `internal/gui`** — derive it from `w.limits` / `fc.limits()`, because z13ctl is + being extended to devices with different envelopes. The design brief for the + eventual daemon-served limits is in z13ctl's `.claude/plans/device-limits-api.md`; + when it lands, only where `Window.limits` is assigned changes. + - Presentation policy stays derived, not fixed: `BasicSliderMax()` is + `TDPMaxSafe - 5`, not a literal 70, because 70 is meaningless on a device whose + safe max is 54. + - `Sanitized()` replaces zero fields with defaults, for the day a daemon older + than the client omits one, **and enforces the ordering/width invariants** that + a zero check cannot see: `TDPMin < TDPMaxSafe <= TDPMaxForced`, and a + temperature axis at least `CurvePoints-1` wide. Each group falls back whole, + because an inconsistent triple does not say which member is wrong. Without the + width check a narrow axis makes `EnforceCurve` emit points below `TempMin` that + the daemon rejects, and `TempMin == TempMax` divides by zero in the editor's + coordinate mapping. The invariant was asserted in the tests but not enforced, + so it held only for limits compiled in — exactly what breaks when the daemon + starts serving them. `HighTDPMinPWM` is exempt from the zero check — 0 + legitimately means "no fan floor on this device" — but is clamped to `PWMMax`. + - `power.Curve` is a fixed `[8]` array. If a device ever needs a different point + count it becomes a slice and the compile-time length guarantee is lost. +- **High-TDP fan floor**: while sustained PL1 exceeds 75W the daemon rejects any fan + curve point below 204 PWM (80%) and refuses a fan reset outright. `fanFloorPWM()` + derives this from applied daemon state (not slider position); `enforceConstraints` + clamps drags to it, `fanCurveEditor.draw` renders the floor line, and `resetFanBtn` is + desensitized with a tooltip pointing at Reset TDP. Both the threshold and the + floor come from `w.limits`, not from literals. +- **Basic vs advanced TDP view**: basic mode is one slider applying a single value to + all three limits, capped at 70W. `power.NeedsAdvanced` decides whether a state can be + shown there; `syncCustomView` force-checks the Advanced box when it cannot. Without + that the slider clamps, the label misreports the hardware, and a save sends the + clamped value — silently lowering the user's power limit. - **Subscribe loop**: background goroutine, exponential backoff reconnect, dispatches `Toggle()` onto the GTK main thread via `glib.TimeoutAdd(0, ...)` followed by `MainContextDefault().Wakeup()` (the wakeup is required — the loop may deliver an @@ -130,6 +231,15 @@ contrib/ out of the struct makes it unreachable from the main thread and removes any chance of an unsynchronized read. Do not promote it to a field — reading it from `show()`/ `hide()` would be a data race, and `internal/gui` is not covered by `go test -race`. +- **Anything painted rather than styled must apply the scale and theme itself.** + The fan curve chart is Cairo, so it reads neither the `@z13-*` tokens nor the + gamescope CSS scaling. `Backend.Scale()` returns 1.0 on layer-shell and the + resolution factor under gamescope; `Window.colors` holds the active palette + alongside the CSS built from it. Every dimension in `fanCurveEditor.draw` and + `hitTest` multiplies by `fc.scale()` — `.fan-curve-area` grew with resolution + while the 6px points and 20px grab radius stayed at 1x, so the drag targets got + harder to hit the larger the output. `applyTheme`/`applyCustomAccent` call + `redrawFanCurve()`, since swapping the CSS provider does not repaint Cairo. - **CSS architecture**: - `layout.css` → `STYLE_PROVIDER_PRIORITY_APPLICATION` (structural, not overridable) - `theme-default.css` → `STYLE_PROVIDER_PRIORITY_USER` (colors, user-overridable) @@ -142,6 +252,8 @@ contrib/ - `.section-label` — section headers ("TDP", "UNDERVOLT", "FAN CURVE"): 11px, bold, letter-spaced, dim - `.scale-name` — slider name labels ("PL1 (SPL)", "CPU Curve Optimizer"): 10px, bold, no letter-spacing, dim - `.scale-value` — slider value readouts ("50 W", "CPU CO: -20"): 10px, normal weight, bright + - `.error-bar` / `.error-text` / `.error-dismiss` — error surface; colored via the + `@z13-error` theme token, as is `.tdp-warning` - **Profile selector**: buttons (`gtk.Button`), stored in `w.profileBtns map[string]*gtk.Button`. Not DropDown (popup broken in gamescope). - **Focus-loss dismiss** (layer-shell): `EventControllerMotion` tracks `pointerInside` @@ -175,16 +287,31 @@ The gamescope backend renders z13gui as an X11 overlay in Steam Gaming Mode. - **Popups don't work**: GTK4 popovers/dropdowns create separate X11 windows that gamescope doesn't composite. Solved via view switching (see below). -### View switching (gamescope only) +### View switching -In both KDE and gamescope modes, `buildContent()` wraps content in a `gtk.Stack` with 4 pages: +`buildContent()` wraps content in a `gtk.Stack` with 4 pages, in **both** backends: - `"main"` — normal drawer (profiles, RGB, battery, etc.) - `"custom"` — custom profile view (TDP, fan curve, undervolt, telemetry) -- `"theme"` — theme picker (radio buttons + accent dots, replaces popover in gamescope) -- `"color"` — HSL color picker (H/S/L sliders + presets + preview, replaces popover in gamescope) +- `"theme"` — theme picker (radio buttons + accent dots) +- `"color"` — HSL color picker (H/S/L sliders + presets + preview) Bottom bar stays visible across all views. `hide()` resets to "main". -In KDE mode, theme/color views use popovers instead of stack pages. + +**There are no popovers left anywhere.** The stack replaced them in both modes +(`e19f76f`, which added gamepad support) because one navigable widget tree is what +makes gamepad focus work identically in both backends — not only because popovers +are uncompositable under gamescope. `grep Popover internal/` returns nothing, and +it should stay that way; reintroducing one would need a second focus-list mechanism +and would be invisible in Gaming Mode. + +That conversion left CSS behind, which is worth knowing about because it hid a real +regression for months: `popover.z13-popover` rules (12 of them) and +`.bottom-bar menubutton > button` outlived the widgets they selected, and the +latter was the theme-picker button's only colour styling — so it silently fell back +to stock GTK colours and stopped following the theme. Both are now removed, with +`.bottom-bar button` / `.view-back-btn` styled directly. **When a widget type +changes, grep the CSS for its element selector**: a rule that no longer matches +fails silently and looks like a theming gap rather than dead code. ### Service environment @@ -273,10 +400,16 @@ make release # goreleaser build + publish Requires at build time: `gtk4-layer-shell` C library (`pkg-config gtk4-layer-shell-0`). -`make test` enumerates the pure-Go packages explicitly (`internal/theme`, -`internal/togglegate`) rather than using `./...`, because `internal/gui` needs CGO and -GTK4 headers. **Add new pure-Go packages to the `test` and `cover` targets** — otherwise -their tests never run; there is no CI test job (the only workflow is release). +`make test` derives its package list from `go list ./internal/...` minus +`internal/gui`, because `internal/gui` needs CGO and GTK4 headers while `go list` +only reads source. A new pure package is therefore tested automatically. `make race` +runs the same set under the race detector; `make cover` reports per-function +coverage. + +CI (`.github/workflows/ci.yml`) runs tests + race on plain ubuntu and +build + gofmt + lint in the same Arch container as the release job. Before this +existed nothing ran tests on a push, which is how PR #10 merged tests that never +executed. ## Known GTK issues (do not re-introduce) @@ -293,7 +426,9 @@ their tests never run; there is no CI test job (the only workflow is release). - **`box-shadow` on animated containers** — shadow pixels extend outside the widget clip region and are not cleared each frame in Wayland Vulkan rendering, causing smearing. - **GTK4 popovers in gamescope** — create separate override-redirect X11 windows that - gamescope doesn't composite. Use `gtk.Stack` view switching instead (gamescope only). + gamescope doesn't composite. Use `gtk.Stack` view switching instead, in **both** + backends: one widget tree is what lets the gamepad focus grid work the same way in + each, so a KDE-only popover would still be the wrong answer. - **GDK_SCALE in gamescope** — causes double scaling (GTK scales buffer, then gamescope scaler scales again). Use manual CSS scaling via `scaledCSS()` instead. - **GtkDropDown in gamescope** — popup list is a separate X11 window. Use buttons or diff --git a/Makefile b/Makefile index 1d774bc..ebe6361 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,14 @@ PREFIX ?= /usr/local HIDBLOCKER_DIR := internal/gui/gamepad/hidblocker -.PHONY: build test cover lint mod-tidy vmlinux generate snapshot release install setcap install-service uninstall-service install-desktop clean help +# PURE_PKGS is every internal package that compiles without CGO and GTK4 headers, +# derived rather than hand-listed so a newly added package is tested automatically. +# `go test ./...` cannot be used because internal/gui needs both; `go list` only +# reads the source, so it works without them. Anything under internal/gui is +# excluded by construction — that is the boundary: widgets there, decisions here. +PURE_PKGS := $(shell go list ./internal/... 2>/dev/null | grep -v '/internal/gui') + +.PHONY: build test race cover lint fmt-check mod-tidy vmlinux generate snapshot release install setcap install-service uninstall-service install-desktop clean help ## build: compile z13gui (CGO required for GTK4) build: @@ -12,17 +19,38 @@ build: ## test: run unit tests (pure Go; no GTK4 headers required) test: - go test ./internal/theme/... ./internal/togglegate/... + go test $(PURE_PKGS) + +## race: run unit tests under the race detector +race: + go test -race $(PURE_PKGS) ## cover: run tests with coverage report cover: - go test -coverprofile=coverage.out ./internal/theme/... ./internal/togglegate/... + go test -coverprofile=coverage.out $(PURE_PKGS) go tool cover -func=coverage.out -## lint: run golangci-lint -lint: +## lint: check formatting, then run golangci-lint +lint: fmt-check golangci-lint run ./... +## fmt-check: fail if any file needs gofmt +# +# gofmt is not among golangci-lint's enabled linters, so `make lint` alone cannot +# see formatting — which made a gofmt slip discoverable only after a push, from +# CI. The CI job calls this target rather than carrying its own copy of the +# command, so local and CI agree by construction instead of by remembering to +# update both. The generated bpf2go bindings are excluded: they are committed as +# the tool emits them. +fmt-check: + @unformatted="$$(gofmt -l . | grep -v '^$(HIDBLOCKER_DIR)/blocker_' || true)"; \ + if [ -n "$$unformatted" ]; then \ + echo "These files need gofmt:"; \ + echo "$$unformatted"; \ + echo "Run: gofmt -w "; \ + exit 1; \ + fi + ## mod-tidy: tidy go.mod mod-tidy: go mod tidy diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..d347ea8 --- /dev/null +++ b/NOTICE @@ -0,0 +1,48 @@ +z13gui +Copyright 2026 Jeff Hagadorn + +This product includes software developed by Jeff Hagadorn. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +See the LICENSE file for the full terms. + +The project names are not covered by the Apache License — see TRADEMARK.md. + +-------------------------------------------------------------------------------- +Bundled third-party components +-------------------------------------------------------------------------------- + +Inter (typeface) + internal/gui/fonts/Inter-Regular.ttf + internal/gui/fonts/Inter-Bold.ttf + + Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + Licensed under the SIL Open Font License, Version 1.1. + Full license text: internal/gui/fonts/LICENSE-Inter.txt + + These files are embedded into the z13gui binary with //go:embed, so every + built binary and every release artifact contains the font. The OFL requires + its notice to travel with the font, which is why LICENSE-Inter.txt is shipped + in the release archive and installed by the distribution packages rather than + only living in the source tree. + +-------------------------------------------------------------------------------- +Components linked but not distributed +-------------------------------------------------------------------------------- + +z13gui links dynamically against GTK 4 (LGPL-2.1-or-later) and +gtk4-layer-shell (MIT), and statically against its Go module dependencies. +Neither the shared libraries nor the Go dependencies' source are redistributed +here; their licenses are recorded in go.mod / go.sum and in the respective +upstream projects. Run `go list -m all` for the current dependency set. + +The eBPF program in internal/gui/gamepad/hidblocker/blocker.bpf.c declares +"GPL" in its license section. That declaration is required by the kernel to +call GPL-only BPF helpers (BPF_CORE_READ expands to bpf_probe_read_kernel) and +is what the kernel's module licensing check reads — it is not a statement about +this repository's copyright license. See that file's header for the specifics. diff --git a/contrib/aur/PKGBUILD b/contrib/aur/PKGBUILD index 57200e5..55c3c71 100644 --- a/contrib/aur/PKGBUILD +++ b/contrib/aur/PKGBUILD @@ -16,6 +16,9 @@ sha256sums=('SHA256_PLACEHOLDER') package() { install -Dm755 "z13gui" "${pkgdir}/usr/bin/z13gui" install -Dm644 "LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 "NOTICE" "${pkgdir}/usr/share/licenses/${pkgname}/NOTICE" + # Inter is compiled into the binary, so the OFL notice ships with it. + install -Dm644 "internal/gui/fonts/LICENSE-Inter.txt" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-Inter.txt" install -Dm644 "contrib/z13gui.service" "${pkgdir}/usr/lib/systemd/user/z13gui.service" install -Dm644 "contrib/z13gui.desktop" "${pkgdir}/usr/share/applications/z13gui.desktop" install -Dm644 "contrib/99-z13gui-gamepad.rules" "${pkgdir}/usr/lib/udev/rules.d/99-z13gui-gamepad.rules" diff --git a/docs/contributing.md b/docs/contributing.md index c66b2e1..98d279b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -19,6 +19,11 @@ Single Go module (`github.com/dahui/z13gui`). | `internal/gui/fonts` | Embedded Inter font registration | | `internal/theme` | Color definitions, TOML parsing, CSS generation — pure Go | +Decisions live in pure packages outside `internal/gui`; the GTK files read +widgets, call out, and apply the answer. `internal/gui` cannot be compiled by the +test tool at all, so anything left in there is unverifiable by construction — +including `internal/gui/gamepad`, which needs no CGO but is excluded by path. + --- ## Development setup @@ -70,16 +75,22 @@ go work init . ../z13ctl/api make build # compile (requires GTK4 headers) make lint # run golangci-lint make test # run unit tests (pure Go, no GTK4 required) +make race # the same tests under the race detector ``` -Tests live in the pure-Go packages (`internal/theme`, `internal/togglegate`) — no +Tests live in the pure-Go packages — everything under `internal/` except +`internal/gui` — with no hardware or GTK4 dependency. GUI packages are integration-tested manually against hardware. -`make test` lists those packages explicitly instead of using `./...`, since -`internal/gui` requires CGO and GTK4 headers. If you add a new pure-Go package with -tests, add it to the `test` and `cover` targets in the Makefile or its tests will -never run. +`make test` derives that list with `go list ./internal/... | grep -v /internal/gui` +rather than using `./...`, since `internal/gui` requires CGO and GTK4 headers. A new +pure package is picked up automatically — there is nothing to register. + +Because `internal/gui` cannot be compiled by the test tool at all, prefer putting +logic in a pure package and leaving only widget wiring behind. That is not a style +preference: it is the difference between code that can be verified and code that +cannot. Pull requests must pass `make build`, `make lint`, and `make test` without errors, and should include tests for any changes to the pure-Go packages. @@ -90,6 +101,15 @@ and should include tests for any changes to the pure-Go packages. - `internal/theme` — fully unit-testable; covers color parsing, CSS generation, config persistence, and all 78 built-in theme/accent combinations +- `internal/power` — TDP limits and fan curve rules +- `internal/daemon` — collapsing an api `(handled, err)` pair into one error, plus + a contract test pinning the api's "daemon not running" convention +- `internal/focusgrid` — gamepad focus navigation index math +- `internal/keyrepeat` — which held direction owns the gamepad auto-repeat +- `internal/colorconv` — hex/HSL/RGB conversion and colour validation +- `internal/lighting` — RGB mode resolution and per-mode controls +- `internal/uiscale` — gamescope UI scale factor +- `internal/startup` — CLI argument scanning and log filtering - `internal/togglegate` — pure debounce helper for duplicate `gui-toggle` bursts - `internal/gui` — requires GTK4; integration-tested manually against hardware - Display backends (layershell, gamescope) — require a compositor or gamescope; diff --git a/docs/theming.md b/docs/theming.md index 8ab7065..b7dfef3 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -94,6 +94,9 @@ text_dim = "#888888" # Border color — window outline and separators border = "#444444" + +# Error color — error bar text and border, high-TDP warning text +error = "#ff4444" ``` Comments, inline comments, unknown keys, and missing keys are all handled @@ -143,6 +146,7 @@ selected. | `text` | `@z13-text` | Primary text, labels, button text | | `text_dim` | `@z13-text-dim` | Section headings (MODE, SPEED, etc.), secondary labels | | `border` | `@z13-border` | Drawer border, separators, button outlines | +| `error` | `@z13-error` | Error bar text and border, high-TDP warning text | --- @@ -174,7 +178,7 @@ All four Catppuccin themes support the 14 official accent colors: For complete control, provide a full GTK4 CSS stylesheet at `~/.config/z13gui/theme.css`. This replaces the built-in theme CSS entirely. -The stylesheet should define all 7 `@define-color` variables: +The stylesheet should define all 8 `@define-color` variables: ```css @define-color z13-accent #cc0000; @@ -184,6 +188,7 @@ The stylesheet should define all 7 `@define-color` variables: @define-color z13-text #e0e0e0; @define-color z13-text-dim #888888; @define-color z13-border #444444; +@define-color z13-error #ff4444; ``` You can then add any GTK4 CSS rules. `theme.toml` takes priority over diff --git a/go.mod b/go.mod index abda775..133764b 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/cilium/ebpf v0.21.0 - github.com/dahui/z13ctl/api v1.1.6 + github.com/dahui/z13ctl/api v1.1.7 github.com/diamondburned/gotk4-layer-shell/pkg v0.0.0-20240109211357-6efa9f6dc438 github.com/diamondburned/gotk4/pkg v0.3.1 github.com/holoplot/go-evdev v0.0.0-20260504100651-66d1748fe847 diff --git a/go.sum b/go.sum index 66d2281..3dcd1ce 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/KarpelesLab/weak v0.1.1 h1:fNnlPo3aypS9tBzoEQluY13XyUfd/eWaSE/vMvo9s4 github.com/KarpelesLab/weak v0.1.1/go.mod h1:pzXsWs5f2bf+fpgHayTlBE1qJpO3MpJKo5sRaLu1XNw= github.com/cilium/ebpf v0.21.0 h1:4dpx1J/B/1apeTmWBH5BkVLayHTkFrMovVPnHEk+l3k= github.com/cilium/ebpf v0.21.0/go.mod h1:1kHKv6Kvh5a6TePP5vvvoMa1bclRyzUXELSs272fmIQ= -github.com/dahui/z13ctl/api v1.1.6 h1:yF6cTmBAsYtlFqLX2W3VaU1VVq9NDNNOV4MLcdto1Gs= -github.com/dahui/z13ctl/api v1.1.6/go.mod h1:WaV3cyrZkszY22RbnJh05/YXMapnP3BcyiAvtyRLnj8= +github.com/dahui/z13ctl/api v1.1.7 h1:23PpM8A1LJUsB646TFulZxOkFsB2vsRNBblXpTxhioc= +github.com/dahui/z13ctl/api v1.1.7/go.mod h1:WaV3cyrZkszY22RbnJh05/YXMapnP3BcyiAvtyRLnj8= github.com/diamondburned/gotk4-layer-shell/pkg v0.0.0-20240109211357-6efa9f6dc438 h1:Ymnl4B+Fn4srLxXbRV2RY1iHT2SH3oAkOfxeEeMI3Fg= github.com/diamondburned/gotk4-layer-shell/pkg v0.0.0-20240109211357-6efa9f6dc438/go.mod h1:AjrxxF6teeNWgaEg0zIUwoqFtXlVTHlEGZvrOn7RXaQ= github.com/diamondburned/gotk4/pkg v0.3.1 h1:uhkXSUPUsCyz3yujdvl7DSN8jiLS2BgNTQE95hk6ygg= diff --git a/internal/colorconv/colorconv.go b/internal/colorconv/colorconv.go new file mode 100644 index 0000000..00b7f81 --- /dev/null +++ b/internal/colorconv/colorconv.go @@ -0,0 +1,181 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package colorconv converts between the RRGGBB hex strings the z13ctl daemon +// speaks and the HSL components the drawer's colour picker manipulates. +// +// It is a separate package because internal/gui needs CGO and GTK4 headers and +// therefore cannot be unit tested. Colour conversion is arithmetic with a lot of +// branches — the kind of code that is cheap to get subtly wrong and cheap to +// verify — so it lives where tests can reach it. +package colorconv + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// Normalize returns hex as the canonical uppercase RRGGBB the daemon expects, +// and reports whether the input was a colour at all. +// +// It accepts a leading "#" and the 3-digit shorthand because those turn up in +// hand-edited config and in state files written by other tools; both are expanded +// rather than rejected. Anything else fails, and callers must not use the string. +// +// Validating here matters because the drawer feeds daemon state straight into the +// picker: an unparseable value used to become pure black silently, and the next +// apply would have written that black back to the hardware. +func Normalize(hex string) (string, bool) { + s := strings.ToUpper(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(hex), "#"))) + switch len(s) { + case 3: + // #RGB shorthand: each digit doubles. + s = string([]byte{s[0], s[0], s[1], s[1], s[2], s[2]}) + case 6: + default: + return "", false + } + if _, err := strconv.ParseUint(s, 16, 32); err != nil { + return "", false + } + return s, true +} + +// HexToHSL converts a hex colour to HSL: H in [0,360), S and L in [0,100]. +// +// ok is false when hex is not a colour, in which case the components are zero and +// the caller should leave its widgets alone rather than display the result — +// (0,0,0) is indistinguishable from a legitimate black. +func HexToHSL(hex string) (h, s, l float64, ok bool) { + norm, ok := Normalize(hex) + if !ok { + return 0, 0, 0, false + } + v, err := strconv.ParseUint(norm, 16, 32) + if err != nil { + return 0, 0, 0, false + } + r := float64((v>>16)&0xFF) / 255 + g := float64((v>>8)&0xFF) / 255 + b := float64(v&0xFF) / 255 + + maxC := math.Max(r, math.Max(g, b)) + minC := math.Min(r, math.Min(g, b)) + l = (maxC + minC) / 2 + + if maxC == minC { + return 0, 0, l * 100, true // achromatic: hue and saturation are undefined + } + d := maxC - minC + if l > 0.5 { + s = d / (2 - maxC - minC) + } else { + s = d / (maxC + minC) + } + switch maxC { + case r: + h = (g - b) / d + if g < b { + h += 6 + } + case g: + h = (b-r)/d + 2 + case b: + h = (r-g)/d + 4 + } + return h * 60, s * 100, l * 100, true +} + +// RGB converts a hex colour to the 0..1 component floats Cairo takes, so the fan +// curve chart can be drawn in the active theme's colours instead of hardcoded +// ones. ok is false when hex is not a colour, in which case the caller must fall +// back rather than draw the zero value — which is black, and invisible against a +// dark theme's background. +// +// Unlike Normalize it also accepts the 8-digit #rrggbbaa form and discards the +// alpha, because theme colours are a different input from daemon colours. A +// theme.toml may legitimately use it — theme.IsHexColor accepts 3, 6 and 8 digits +// — whereas the daemon's wire format is strictly RRGGBB, so Normalize must keep +// rejecting it. Missing that distinction made every chart element silently fall +// back to the default palette for such a theme. Alpha is dropped rather than +// honoured because the chart chooses its own per-element opacity. +func RGB(hex string) (r, g, b float64, ok bool) { + s := strings.TrimSpace(hex) + if len(strings.TrimPrefix(s, "#")) == 8 { + s = strings.TrimPrefix(s, "#")[:6] + } + norm, ok := Normalize(s) + if !ok { + return 0, 0, 0, false + } + v, err := strconv.ParseUint(norm, 16, 32) + if err != nil { + return 0, 0, 0, false + } + return float64((v>>16)&0xFF) / 255, + float64((v>>8)&0xFF) / 255, + float64(v&0xFF) / 255, + true +} + +// HSLToHex converts HSL components to canonical uppercase RRGGBB. +// H is taken modulo 360; S and L are clamped to [0,100], so slider values can be +// passed straight through. +func HSLToHex(h, s, l float64) string { + h = math.Mod(h, 360) + if h < 0 { + h += 360 + } + s = clamp01(s / 100) + l = clamp01(l / 100) + h /= 360 + + if s == 0 { + v := int(math.Round(l * 255)) + return fmt.Sprintf("%02X%02X%02X", v, v, v) + } + var q float64 + if l < 0.5 { + q = l * (1 + s) + } else { + q = l + s - l*s + } + p := 2*l - q + return fmt.Sprintf("%02X%02X%02X", + int(math.Round(hueToRGB(p, q, h+1.0/3.0)*255)), + int(math.Round(hueToRGB(p, q, h)*255)), + int(math.Round(hueToRGB(p, q, h-1.0/3.0)*255)), + ) +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +// hueToRGB maps one hue-shifted channel back to an intensity. +func hueToRGB(p, q, t float64) float64 { + if t < 0 { + t++ + } + if t > 1 { + t-- + } + switch { + case t < 1.0/6.0: + return p + (q-p)*6*t + case t < 1.0/2.0: + return q + case t < 2.0/3.0: + return p + (q-p)*(2.0/3.0-t)*6 + default: + return p + } +} diff --git a/internal/colorconv/colorconv_test.go b/internal/colorconv/colorconv_test.go new file mode 100644 index 0000000..32448d9 --- /dev/null +++ b/internal/colorconv/colorconv_test.go @@ -0,0 +1,357 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package colorconv + +import ( + "fmt" + "math" + "testing" +) + +func TestNormalize(t *testing.T) { + tests := []struct { + in string + want string + wantOK bool + }{ + {in: "FF6600", want: "FF6600", wantOK: true}, + {in: "ff6600", want: "FF6600", wantOK: true}, + {in: "#FF6600", want: "FF6600", wantOK: true}, + {in: " #ff6600 ", want: "FF6600", wantOK: true}, + {in: "F60", want: "FF6600", wantOK: true}, // shorthand expands + {in: "#f60", want: "FF6600", wantOK: true}, + {in: "000000", want: "000000", wantOK: true}, + {in: "FFFFFF", want: "FFFFFF", wantOK: true}, + + // Rejected. Each of these used to yield pure black silently. + {in: "", wantOK: false}, + {in: "#", wantOK: false}, + {in: "GGGGGG", wantOK: false}, + {in: "FF66", wantOK: false}, + {in: "FF66000", wantOK: false}, + {in: "FF 660", wantOK: false}, + {in: "0x1234", wantOK: false}, + {in: "-F0000", wantOK: false}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%q", tt.in), func(t *testing.T) { + got, ok := Normalize(tt.in) + if ok != tt.wantOK { + t.Fatalf("Normalize(%q) ok = %v, want %v", tt.in, ok, tt.wantOK) + } + if ok && got != tt.want { + t.Errorf("Normalize(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// The regression this package exists for: an unparseable colour must be +// reportable as invalid, not silently indistinguishable from black. The drawer +// takes this value from daemon state, and a corrupt state file used to blacken the +// swatch and then get written back to the hardware on the next apply. +func TestHexToHSLRejectsGarbageInsteadOfReturningBlack(t *testing.T) { + for _, bad := range []string{"", "nonsense", "#", "12345", "GGGGGG"} { + h, s, l, ok := HexToHSL(bad) + if ok { + t.Errorf("HexToHSL(%q) ok = true, want false", bad) + } + if h != 0 || s != 0 || l != 0 { + t.Errorf("HexToHSL(%q) = (%v,%v,%v), want zeroes alongside ok=false", bad, h, s, l) + } + } + + // Real black is valid and must be distinguishable from the failure above by + // the ok flag alone. + if _, _, _, ok := HexToHSL("000000"); !ok { + t.Error("HexToHSL(000000) ok = false, want true — black is a colour") + } +} + +func TestHexToHSLKnownValues(t *testing.T) { + tests := []struct { + hex string + h, s, l float64 + }{ + {hex: "000000", h: 0, s: 0, l: 0}, + {hex: "FFFFFF", h: 0, s: 0, l: 100}, + {hex: "808080", h: 0, s: 0, l: 50.196}, + {hex: "FF0000", h: 0, s: 100, l: 50}, + {hex: "00FF00", h: 120, s: 100, l: 50}, + {hex: "0000FF", h: 240, s: 100, l: 50}, + {hex: "FFFF00", h: 60, s: 100, l: 50}, + {hex: "00FFFF", h: 180, s: 100, l: 50}, + {hex: "FF00FF", h: 300, s: 100, l: 50}, + } + for _, tt := range tests { + t.Run(tt.hex, func(t *testing.T) { + h, s, l, ok := HexToHSL(tt.hex) + if !ok { + t.Fatalf("HexToHSL(%s) ok = false", tt.hex) + } + const tol = 0.01 + if math.Abs(h-tt.h) > tol || math.Abs(s-tt.s) > tol || math.Abs(l-tt.l) > tol { + t.Errorf("HexToHSL(%s) = (%.3f,%.3f,%.3f), want (%v,%v,%v)", tt.hex, h, s, l, tt.h, tt.s, tt.l) + } + }) + } +} + +// Hue is meaningless without saturation, so it must come back as a definite 0 +// rather than whatever the branch arithmetic happened to leave behind. +func TestHexToHSLGreysHaveZeroHueAndSaturation(t *testing.T) { + for _, grey := range []string{"000000", "111111", "808080", "CCCCCC", "FFFFFF"} { + h, s, _, ok := HexToHSL(grey) + if !ok { + t.Fatalf("HexToHSL(%s) ok = false", grey) + } + if h != 0 || s != 0 { + t.Errorf("HexToHSL(%s) = h %v s %v, want both 0", grey, h, s) + } + } +} + +func TestHexToHSLHueIsInRange(t *testing.T) { + for r := 0; r < 256; r += 17 { + for g := 0; g < 256; g += 17 { + for b := 0; b < 256; b += 17 { + hex := fmt.Sprintf("%02X%02X%02X", r, g, b) + h, s, l, ok := HexToHSL(hex) + if !ok { + t.Fatalf("HexToHSL(%s) ok = false", hex) + } + if h < 0 || h >= 360 { + t.Errorf("HexToHSL(%s) h = %v, outside [0,360)", hex, h) + } + if s < 0 || s > 100 { + t.Errorf("HexToHSL(%s) s = %v, outside [0,100]", hex, s) + } + if l < 0 || l > 100 { + t.Errorf("HexToHSL(%s) l = %v, outside [0,100]", hex, l) + } + } + } + } +} + +// The invariant that matters in the picker: opening the colour view converts hex +// to slider positions, and touching a slider converts back. A lossy round trip +// would drift the colour every time the view is opened. +func TestRoundTripHexToHSLAndBack(t *testing.T) { + for r := 0; r < 256; r += 17 { + for g := 0; g < 256; g += 17 { + for b := 0; b < 256; b += 17 { + want := fmt.Sprintf("%02X%02X%02X", r, g, b) + h, s, l, ok := HexToHSL(want) + if !ok { + t.Fatalf("HexToHSL(%s) ok = false", want) + } + if got := HSLToHex(h, s, l); got != want { + t.Errorf("round trip %s -> (%.3f,%.3f,%.3f) -> %s", want, h, s, l, got) + } + } + } + } +} + +func TestHSLToHexAlwaysProducesAValidColour(t *testing.T) { + for h := -720.0; h <= 1080; h += 37 { + for s := -50.0; s <= 150; s += 25 { + for l := -50.0; l <= 150; l += 25 { + got := HSLToHex(h, s, l) + if norm, ok := Normalize(got); !ok || norm != got { + t.Errorf("HSLToHex(%v,%v,%v) = %q, not a canonical colour", h, s, l, got) + } + } + } + } +} + +// Slider values arrive unclamped from GTK and hue wraps, so out-of-range input +// must behave rather than produce something like "-1-1-1". +func TestHSLToHexClampsAndWraps(t *testing.T) { + if got, want := HSLToHex(0, 100, 50), "FF0000"; got != want { + t.Errorf("HSLToHex(0,100,50) = %s, want %s", got, want) + } + if got, want := HSLToHex(360, 100, 50), "FF0000"; got != want { + t.Errorf("hue 360 = %s, want %s (wraps to 0)", got, want) + } + if got, want := HSLToHex(-360, 100, 50), "FF0000"; got != want { + t.Errorf("hue -360 = %s, want %s (wraps to 0)", got, want) + } + if got, want := HSLToHex(720+120, 100, 50), "00FF00"; got != want { + t.Errorf("hue 840 = %s, want %s (wraps to 120)", got, want) + } + if got, want := HSLToHex(0, 999, 50), "FF0000"; got != want { + t.Errorf("saturation 999 = %s, want %s (clamped to 100)", got, want) + } + if got, want := HSLToHex(0, 100, 999), "FFFFFF"; got != want { + t.Errorf("lightness 999 = %s, want %s (clamped to 100)", got, want) + } + if got, want := HSLToHex(0, 100, -999), "000000"; got != want { + t.Errorf("lightness -999 = %s, want %s (clamped to 0)", got, want) + } +} + +func TestRGB(t *testing.T) { + tests := []struct { + hex string + r, g, b float64 + ok bool + }{ + {"FF0000", 1, 0, 0, true}, + {"00FF00", 0, 1, 0, true}, + {"0000FF", 0, 0, 1, true}, + {"FFFFFF", 1, 1, 1, true}, + {"000000", 0, 0, 0, true}, + // Theme tokens arrive "#rrggbb" from theme.Colors, so the leading hash and + // lowercase digits both have to work — this is the form the fan curve reads. + {"#cc0000", 0.8, 0, 0, true}, + {"#f38ba8", 243.0 / 255, 139.0 / 255, 168.0 / 255, true}, + // Shorthand, same as Normalize accepts. + {"#f00", 1, 0, 0, true}, + // Not colours: the caller must fall back, not draw black. + {"", 0, 0, 0, false}, + {"nope", 0, 0, 0, false}, + {"#12345", 0, 0, 0, false}, + {"GGGGGG", 0, 0, 0, false}, + } + const eps = 1e-9 + for _, tt := range tests { + r, g, b, ok := RGB(tt.hex) + if ok != tt.ok { + t.Errorf("RGB(%q) ok = %v, want %v", tt.hex, ok, tt.ok) + continue + } + if math.Abs(r-tt.r) > eps || math.Abs(g-tt.g) > eps || math.Abs(b-tt.b) > eps { + t.Errorf("RGB(%q) = (%v, %v, %v), want (%v, %v, %v)", + tt.hex, r, g, b, tt.r, tt.g, tt.b) + } + } +} + +// TestRGBComponentsInRange is the invariant Cairo depends on: SetSourceRGBA +// silently clamps, so an out-of-range component would be a colour that is wrong +// rather than an error anyone notices. +func TestRGBComponentsInRange(t *testing.T) { + for v := 0; v <= 0xFFFFFF; v += 7919 { // prime stride, ~2100 samples + hex := fmt.Sprintf("%06X", v) + r, g, b, ok := RGB(hex) + if !ok { + t.Fatalf("RGB(%q) not ok", hex) + } + for name, c := range map[string]float64{"r": r, "g": g, "b": b} { + if c < 0 || c > 1 { + t.Fatalf("RGB(%q) %s = %v, outside [0,1]", hex, name, c) + } + } + } +} + +// TestRGBMatchesEveryBuiltinThemeToken would be circular if it lived in the theme +// package; here it just checks that the string form theme.Colors uses is one RGB +// accepts, for every token the fan curve might read. +func TestRGBAcceptsThemeTokenForm(t *testing.T) { + for _, hex := range []string{"#cc0000", "#1a1a1a", "#e0e0e0", "#888888", "#ff4444"} { + if _, _, _, ok := RGB(hex); !ok { + t.Errorf("RGB(%q) rejected a theme token", hex) + } + } +} + +// TestRGBAcceptsEveryFormIsHexColorDoes ties RGB to the theme parser's notion of +// a colour. theme.IsHexColor accepts 3, 6 and 8 digits, so a theme.toml may carry +// an #rrggbbaa value; RGB rejecting it made every fan curve element silently fall +// back to the default palette for that theme. +// +// The alpha is dropped, not honoured: the chart picks its own per-element opacity. +func TestRGBAcceptsEveryFormIsHexColorDoes(t *testing.T) { + tests := []struct { + hex string + r, g, b float64 + }{ + {"#f00", 1, 0, 0}, + {"#ff0000", 1, 0, 0}, + {"#ff0000aa", 1, 0, 0}, + {"#FF0000AA", 1, 0, 0}, + {"#89b4fa80", 137.0 / 255, 180.0 / 255, 250.0 / 255}, + // Bare, as Normalize also allows. + {"ff0000aa", 1, 0, 0}, + } + const eps = 1e-9 + for _, tt := range tests { + r, g, b, ok := RGB(tt.hex) + if !ok { + t.Errorf("RGB(%q) rejected a form theme.IsHexColor accepts", tt.hex) + continue + } + if math.Abs(r-tt.r) > eps || math.Abs(g-tt.g) > eps || math.Abs(b-tt.b) > eps { + t.Errorf("RGB(%q) = (%v, %v, %v), want (%v, %v, %v)", + tt.hex, r, g, b, tt.r, tt.g, tt.b) + } + } +} + +// The daemon's wire format is strictly RRGGBB, so loosening RGB must not have +// loosened Normalize with it — an 8-digit value reaching the hardware would be +// wrong, and silently writing it back is the bug Normalize was added to stop. +func TestNormalizeStillRejectsAlphaForms(t *testing.T) { + for _, hex := range []string{"#ff0000aa", "ff0000aa", "#FF0000AA", "#f00a"} { + if got, ok := Normalize(hex); ok { + t.Errorf("Normalize(%q) = %q, ok — the daemon format has no alpha", hex, got) + } + } +} + +// TestHSLRoundTrip is the invariant the colour picker rests on: opening it sets +// the sliders from the stored hex, and every slider change converts straight back. +// Any drift would mean a colour that shifts when the user opens the picker and +// nudges a slider back to where it started. +// +// Verified exact across the sampled space rather than approximately, because there +// is no rounding budget to spend — the daemon stores the hex the drawer sends. +func TestHSLRoundTrip(t *testing.T) { + // Prime stride so the samples do not align with channel boundaries. + for v := 0; v <= 0xFFFFFF; v += 4993 { + hex := fmt.Sprintf("%06X", v) + h, s, l, ok := HexToHSL(hex) + if !ok { + t.Fatalf("HexToHSL(%q) not ok", hex) + } + if back := HSLToHex(h, s, l); back != hex { + t.Errorf("round trip drifted: %s -> H%.4f S%.4f L%.4f -> %s", hex, h, s, l, back) + } + } +} + +// The greys are the interesting case: saturation is undefined there, so hue is +// reported as 0 and has to be reconstructed without shifting the colour. +func TestHSLRoundTripGreys(t *testing.T) { + for c := 0; c <= 0xFF; c++ { + hex := fmt.Sprintf("%02X%02X%02X", c, c, c) + h, s, l, ok := HexToHSL(hex) + if !ok { + t.Fatalf("HexToHSL(%q) not ok", hex) + } + if s != 0 { + t.Errorf("%s: saturation = %v, want 0 for a grey", hex, s) + } + if back := HSLToHex(h, s, l); back != hex { + t.Errorf("grey round trip drifted: %s -> %s", hex, back) + } + } +} + +// Every preset and swatch default a user can actually click. +func TestHSLRoundTripPresets(t *testing.T) { + for _, hex := range []string{ + "FF0000", "FF6600", "FFFF00", "00FF00", + "00FFFF", "0000FF", "FF00FF", "FFFFFF", "000000", + } { + h, s, l, _ := HexToHSL(hex) + if back := HSLToHex(h, s, l); back != hex { + t.Errorf("preset %s round-tripped to %s", hex, back) + } + } +} diff --git a/internal/daemon/contract_test.go b/internal/daemon/contract_test.go new file mode 100644 index 0000000..c5fb27a --- /dev/null +++ b/internal/daemon/contract_test.go @@ -0,0 +1,65 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/dahui/z13ctl/api" +) + +// TestAPIReportsMissingDaemonWithoutAnError pins the api behaviour this whole +// package exists for: when the socket cannot be dialled, api.Send* reports +// handled=false and a *nil* error. Every call site in internal/gui used to test +// err alone, which turned "the daemon is not running" into a success. +// +// Asserting it here rather than trusting the doc comment means an api release +// that changed the convention would fail the build instead of silently making +// daemon.Err redundant — or, worse, wrong. +// +// api.SocketPath derives the path from XDG_RUNTIME_DIR, so pointing that at an +// empty temp dir guarantees no daemon is reachable without going near the real +// one. The calls below therefore never reach hardware. +func TestAPIReportsMissingDaemonWithoutAnError(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_RUNTIME_DIR", dir) + + if got := api.SocketPath(); !filepath.IsAbs(got) || filepath.Dir(filepath.Dir(got)) != dir { + t.Fatalf("socket path %q is not inside the temp dir %q — refusing to run "+ + "against a possibly real daemon", got, dir) + } + + t.Run("get-state", func(t *testing.T) { + handled, state, err := api.SendGetState() + if handled { + t.Error("handled = true with no daemon listening") + } + if err != nil { + t.Errorf("err = %v, want nil (the api signals absence via handled)", err) + } + if state != nil { + t.Error("state is non-nil with no daemon listening") + } + if got := Err(handled, err); !errors.Is(got, ErrNotRunning) { + t.Errorf("Err(%v, %v) = %v, want ErrNotRunning", handled, err, got) + } + }) + + // One mutating command, to show the convention is not special to get-state. + // With no socket to dial this cannot touch the hardware. + t.Run("tdp-reset", func(t *testing.T) { + handled, err := api.SendTdpReset() + if handled { + t.Error("handled = true with no daemon listening") + } + if err != nil { + t.Errorf("err = %v, want nil", err) + } + if got := Err(handled, err); !errors.Is(got, ErrNotRunning) { + t.Errorf("Err(%v, %v) = %v, want ErrNotRunning", handled, err, got) + } + }) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..5bccc89 --- /dev/null +++ b/internal/daemon/daemon.go @@ -0,0 +1,45 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package daemon turns a z13ctl api call's result pair into a single error. +// +// Every api.Send* function returns (handled bool, err error), where handled is +// false and err is nil when the daemon is not running — the socket dial failed, +// so nothing was ever sent. Reading only err therefore treats "the daemon is not +// there" as success. +// +// That is not a hypothetical: the drawer used to discard handled at all thirteen +// call sites, so stopping the daemon and pressing Save TDP hid any existing +// error, logged "custom TDP saved" and left the typed values on screen. It is the +// same class of failure as z13ctl issue #14 — a control that reports success +// without doing anything — which is exactly what the error bar exists to prevent. +// +// It is its own package for the usual reason: internal/gui needs CGO and GTK4 +// headers and cannot be unit tested, so the decision lives out here where it can +// be. Err is shaped to take an api call's results directly: +// +// if err := daemon.Err(api.SendTdpReset()); err != nil { +package daemon + +import "errors" + +// ErrNotRunning reports that the daemon was unreachable, so the request was +// never sent. Worded for the error bar, which shows it to the user verbatim. +var ErrNotRunning = errors.New("z13ctl daemon is not running") + +// Err collapses an api result pair into one error: nil only when the daemon +// handled the request and reported no failure. +// +// A real error wins over ErrNotRunning. The api layer only sets handled=false +// when the dial itself failed, in which case err is nil, so the two cannot +// disagree today — preferring err keeps it that way if that ever changes, since +// a specific message is always more useful than a generic one. +func Err(handled bool, err error) error { + if err != nil { + return err + } + if !handled { + return ErrNotRunning + } + return nil +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..7747fb8 --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "errors" + "fmt" + "testing" +) + +func TestErr(t *testing.T) { + boom := errors.New("permission denied") + + tests := []struct { + name string + handled bool + err error + want error + }{ + {"handled and no failure", true, nil, nil}, + {"handled but rejected", true, boom, boom}, + { + // The case the drawer used to read as success at every call site. + name: "daemon not running", + handled: false, + err: nil, + want: ErrNotRunning, + }, + { + // Cannot happen with api v1.1.7, which returns err==nil whenever the + // dial fails. Pinned so a future api that reports both keeps the more + // specific message. + name: "not handled with an error prefers the error", + handled: false, + err: boom, + want: boom, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Err(tt.handled, tt.err); !errors.Is(got, tt.want) { + t.Errorf("Err(%v, %v) = %v, want %v", tt.handled, tt.err, got, tt.want) + } + }) + } +} + +// TestErrTakesAPIResultsDirectly pins the calling convention the GTK code uses: +// an api.Send* call is passed as the sole argument, so no site can read err while +// forgetting handled. +func TestErrTakesAPIResultsDirectly(t *testing.T) { + sendUnreachable := func() (bool, error) { return false, nil } + if err := Err(sendUnreachable()); !errors.Is(err, ErrNotRunning) { + t.Errorf("Err(sendUnreachable()) = %v, want %v", err, ErrNotRunning) + } + + sendOK := func() (bool, error) { return true, nil } + if err := Err(sendOK()); err != nil { + t.Errorf("Err(sendOK()) = %v, want nil", err) + } +} + +// TestErrNotRunningIsUserFacing guards the message itself: it is concatenated +// into the error bar as "Save TDP: ", so it has to read as a sentence +// fragment a user can act on rather than as a Go identifier. +func TestErrNotRunningIsUserFacing(t *testing.T) { + msg := ErrNotRunning.Error() + if got := fmt.Sprintf("Save TDP: %v", ErrNotRunning); got != "Save TDP: "+msg { + t.Errorf("unexpected formatting: %q", got) + } + for _, bad := range []string{"ErrNotRunning", "nil", "%!"} { + if msg == bad { + t.Errorf("ErrNotRunning message is not user-facing: %q", msg) + } + } + if msg == "" { + t.Error("ErrNotRunning has an empty message") + } +} diff --git a/internal/focusgrid/focusgrid.go b/internal/focusgrid/focusgrid.go new file mode 100644 index 0000000..2618dca --- /dev/null +++ b/internal/focusgrid/focusgrid.go @@ -0,0 +1,219 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package focusgrid is the gamepad focus navigation for the drawer: given a set +// of items laid out in rows, columns and sections, it answers "which item does +// this D-pad press move to". +// +// It exists as a separate package because internal/gui needs CGO and GTK4 headers +// and so cannot be unit tested, while this is the input path every gamepad user in +// Steam Gaming Mode depends on. It is pure index arithmetic over a snapshot: the +// GTK layer evaluates each item's visibility once, calls a function here, and +// applies the returned index. No widgets, no GTK types. +// +// Every function takes the current index and returns the new one. They return +// idx unchanged rather than a sentinel when a move is impossible, so callers can +// apply the result unconditionally. +// +// # Bounds +// +// All functions tolerate an out-of-range idx by returning it unchanged. The GTK +// side previously guarded this in four of its seven entry points and not the other +// three, which was a latent panic and left the intended contract ambiguous. It is +// stated here once instead. +package focusgrid + +import "sort" + +// Item is one navigable element, flattened from the widget tree. Visible is +// evaluated by the caller before navigating, so a single press sees a consistent +// snapshot even if widget visibility changes underneath it. +type Item struct { + Row int // visual row + Col int // column within the row + Section string // grouping for shoulder-button jumps + Visible bool +} + +// inRange reports whether idx addresses an item. +func inRange(items []Item, idx int) bool { + return idx >= 0 && idx < len(items) +} + +// FirstVisible returns the index of the first visible item, or -1 if none are. +// Used when focus is first shown and after switching views. +func FirstVisible(items []Item) int { + for i := range items { + if items[i].Visible { + return i + } + } + return -1 +} + +// visibleRows returns the sorted, unique row numbers that contain at least one +// visible item. +func visibleRows(items []Item) []int { + seen := make(map[int]bool) + var rows []int + for i := range items { + if items[i].Visible && !seen[items[i].Row] { + seen[items[i].Row] = true + rows = append(rows, items[i].Row) + } + } + sort.Ints(rows) + return rows +} + +// MoveVertical moves focus to the next (dir=+1) or previous (dir=-1) row that has +// a visible item, wrapping at the ends. Within the target row it picks the item +// whose column is nearest the current one, so travelling down a column of +// differently-shaped rows stays roughly in line. +// +// Ties go to the lower column: with the cursor between two equidistant items, +// moving down twice and back up twice returns to where it started. +func MoveVertical(items []Item, idx, dir int) int { + if !inRange(items, idx) { + return idx + } + rows := visibleRows(items) + if len(rows) <= 1 { + return idx // nowhere else to go + } + cur := items[idx] + + curPos := -1 + for i, r := range rows { + if r == cur.Row { + curPos = i + break + } + } + if curPos == -1 { + // The focused item is itself hidden — its row is not in the visible set. + // Fall back to the first visible item rather than refusing to move, which + // would otherwise trap focus on an invisible widget. + if first := FirstVisible(items); first >= 0 { + return first + } + return idx + } + + targetRow := rows[((curPos+dir)%len(rows)+len(rows))%len(rows)] + + best := -1 + bestDist := 0 + for i := range items { + if items[i].Row != targetRow || !items[i].Visible { + continue + } + d := cur.Col - items[i].Col + if d < 0 { + d = -d + } + // The tie-break is on column, not on position in the slice. Taking the + // first equidistant item found made the result depend on the order the + // caller happened to append its rows in: every list built today runs + // left to right, so this matched, but nothing enforced it and the rule + // documented above quietly did not hold for any other order. + switch { + case best == -1, d < bestDist: + best, bestDist = i, d + case d == bestDist && items[i].Col < items[best].Col: + best = i + } + } + if best == -1 { + return idx + } + return best +} + +// MoveHorizontal moves focus to the next (dir=+1) or previous (dir=-1) visible +// item in the same row, ordered by column and wrapping at the row's edges. +func MoveHorizontal(items []Item, idx, dir int) int { + if !inRange(items, idx) { + return idx + } + row := items[idx].Row + + var rowItems []int + for i := range items { + if items[i].Row == row && items[i].Visible { + rowItems = append(rowItems, i) + } + } + if len(rowItems) <= 1 { + return idx + } + sort.Slice(rowItems, func(a, b int) bool { + return items[rowItems[a]].Col < items[rowItems[b]].Col + }) + + pos := -1 + for i, ri := range rowItems { + if ri == idx { + pos = i + break + } + } + if pos == -1 { + // Focused item is hidden; enter the row at its first item. + return rowItems[0] + } + next := ((pos+dir)%len(rowItems) + len(rowItems)) % len(rowItems) + return rowItems[next] +} + +// Sections returns the section names in visual order — by row, then by the order +// items appear within a row — deduplicated. +func Sections(items []Item) []string { + var out []string + seen := make(map[string]bool) + for _, r := range visibleRows(items) { + for i := range items { + if items[i].Row == r && items[i].Visible && !seen[items[i].Section] { + seen[items[i].Section] = true + out = append(out, items[i].Section) + } + } + } + return out +} + +// JumpSection moves focus to the first visible item of the next (dir=+1) or +// previous (dir=-1) section, wrapping. Driven by the shoulder buttons. +func JumpSection(items []Item, idx, dir int) int { + if !inRange(items, idx) { + return idx + } + sections := Sections(items) + if len(sections) <= 1 { + return idx + } + cur := items[idx].Section + + curPos := -1 + for i, s := range sections { + if s == cur { + curPos = i + break + } + } + if curPos == -1 { + // Focused item is hidden, so its section may not be in the visible set. + if first := FirstVisible(items); first >= 0 { + return first + } + return idx + } + + target := sections[((curPos+dir)%len(sections)+len(sections))%len(sections)] + for i := range items { + if items[i].Section == target && items[i].Visible { + return i + } + } + return idx +} diff --git a/internal/focusgrid/focusgrid_test.go b/internal/focusgrid/focusgrid_test.go new file mode 100644 index 0000000..cba1282 --- /dev/null +++ b/internal/focusgrid/focusgrid_test.go @@ -0,0 +1,428 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package focusgrid + +import "testing" + +// grid mirrors the shape of the drawer's main view: a couple of full-width rows, +// a two-column row, and a section boundary. +func grid() []Item { + return []Item{ + {Row: 0, Col: 0, Section: "tabs", Visible: true}, + {Row: 0, Col: 1, Section: "tabs", Visible: true}, + {Row: 1, Col: 0, Section: "mode", Visible: true}, + {Row: 2, Col: 0, Section: "mode", Visible: true}, + {Row: 2, Col: 1, Section: "mode", Visible: true}, + {Row: 2, Col: 2, Section: "mode", Visible: true}, + {Row: 3, Col: 0, Section: "battery", Visible: true}, + } +} + +func TestFirstVisible(t *testing.T) { + if got := FirstVisible(grid()); got != 0 { + t.Errorf("FirstVisible = %d, want 0", got) + } + + hidden := grid() + hidden[0].Visible = false + hidden[1].Visible = false + if got := FirstVisible(hidden); got != 2 { + t.Errorf("FirstVisible with first row hidden = %d, want 2", got) + } + + for i := range hidden { + hidden[i].Visible = false + } + if got := FirstVisible(hidden); got != -1 { + t.Errorf("FirstVisible with nothing visible = %d, want -1", got) + } + if got := FirstVisible(nil); got != -1 { + t.Errorf("FirstVisible(nil) = %d, want -1", got) + } +} + +// Out-of-range indices must be returned unchanged rather than panicking. The GTK +// layer used to guard this in four of seven entry points; the contract lives here +// now, so it is asserted for every function. +func TestEveryFunctionToleratesAnOutOfRangeIndex(t *testing.T) { + items := grid() + for _, idx := range []int{-1, -100, len(items), len(items) + 50} { + if got := MoveVertical(items, idx, 1); got != idx { + t.Errorf("MoveVertical(idx=%d) = %d, want %d", idx, got, idx) + } + if got := MoveHorizontal(items, idx, 1); got != idx { + t.Errorf("MoveHorizontal(idx=%d) = %d, want %d", idx, got, idx) + } + if got := JumpSection(items, idx, 1); got != idx { + t.Errorf("JumpSection(idx=%d) = %d, want %d", idx, got, idx) + } + } +} + +func TestEveryFunctionToleratesAnEmptyGrid(t *testing.T) { + for _, items := range [][]Item{nil, {}} { + if got := MoveVertical(items, 0, 1); got != 0 { + t.Errorf("MoveVertical on empty = %d, want 0", got) + } + if got := MoveHorizontal(items, 0, 1); got != 0 { + t.Errorf("MoveHorizontal on empty = %d, want 0", got) + } + if got := JumpSection(items, 0, 1); got != 0 { + t.Errorf("JumpSection on empty = %d, want 0", got) + } + } +} + +func TestMoveVerticalWalksRows(t *testing.T) { + items := grid() + // From row 0 col 0, down through every row and wrapping back to the start. + want := []int{2, 3, 6, 0} + idx := 0 + for step, expect := range want { + idx = MoveVertical(items, idx, 1) + if idx != expect { + t.Fatalf("step %d: idx = %d (row %d), want %d", step, idx, items[idx].Row, expect) + } + } +} + +func TestMoveVerticalWrapsBothWays(t *testing.T) { + items := grid() + if got := MoveVertical(items, 0, -1); items[got].Row != 3 { + t.Errorf("up from the top row landed on row %d, want 3 (wrap)", items[got].Row) + } + if got := MoveVertical(items, 6, 1); items[got].Row != 0 { + t.Errorf("down from the bottom row landed on row %d, want 0 (wrap)", items[got].Row) + } +} + +func TestMoveVerticalPreservesColumn(t *testing.T) { + items := []Item{ + {Row: 0, Col: 0, Visible: true}, + {Row: 0, Col: 1, Visible: true}, + {Row: 0, Col: 2, Visible: true}, + {Row: 1, Col: 0, Visible: true}, + {Row: 1, Col: 1, Visible: true}, + {Row: 1, Col: 2, Visible: true}, + } + // Column 2 in row 0 is index 2; down should land on column 2 of row 1. + got := MoveVertical(items, 2, 1) + if items[got].Col != 2 { + t.Errorf("moved from col 2 to col %d, want col 2 preserved", items[got].Col) + } +} + +func TestMoveVerticalPicksNearestColumnWhenRowIsNarrower(t *testing.T) { + items := []Item{ + {Row: 0, Col: 0, Visible: true}, + {Row: 0, Col: 1, Visible: true}, + {Row: 0, Col: 2, Visible: true}, + {Row: 1, Col: 0, Visible: true}, // narrower row + } + if got := MoveVertical(items, 2, 1); got != 3 { + t.Errorf("from col 2 into a single-column row = %d, want 3", got) + } +} + +// Down-then-up must return to the starting item, or holding the D-pad drifts +// sideways across a grid of uneven rows. +func TestMoveVerticalIsReversible(t *testing.T) { + items := grid() + for start := range items { + down := MoveVertical(items, start, 1) + back := MoveVertical(items, down, -1) + if items[back].Row != items[start].Row { + t.Errorf("from %d: down to %d then up to %d, row %d != %d", + start, down, back, items[back].Row, items[start].Row) + } + } +} + +func TestMoveVerticalSkipsFullyHiddenRows(t *testing.T) { + items := grid() + // Hide all of row 2 — the advanced-TDP case, where a whole section collapses. + for i := range items { + if items[i].Row == 2 { + items[i].Visible = false + } + } + got := MoveVertical(items, 2, 1) // from row 1 + if items[got].Row != 3 { + t.Errorf("landed on row %d, want 3 (row 2 is hidden)", items[got].Row) + } +} + +func TestMoveVerticalWithASingleVisibleRowStaysPut(t *testing.T) { + items := grid() + for i := range items { + items[i].Visible = items[i].Row == 2 + } + if got := MoveVertical(items, 3, 1); got != 3 { + t.Errorf("MoveVertical with one visible row = %d, want 3 (unchanged)", got) + } +} + +// If focus is sitting on a widget that has since been hidden, navigation must +// escape rather than refuse to move — otherwise the gamepad is stuck. +func TestMoveVerticalEscapesAHiddenFocusedItem(t *testing.T) { + items := grid() + items[2].Visible = false // the only item in row 1 + got := MoveVertical(items, 2, 1) + if !items[got].Visible { + t.Errorf("landed on hidden index %d", got) + } +} + +// The original implementation returned early when the focused item's section was +// absent from the visible set, which happens exactly when that item has been +// hidden — leaving the shoulder buttons dead until something else moved focus. +func TestJumpSectionEscapesAHiddenFocusedItem(t *testing.T) { + items := grid() + items[0].Visible = false + items[1].Visible = false // all of section "tabs" is now hidden + + got := JumpSection(items, 0, 1) // focused item is itself hidden + if !items[got].Visible { + t.Errorf("landed on hidden index %d", got) + } + if got == 0 { + t.Error("JumpSection did not move away from the hidden focused item") + } +} + +// Same trap on the horizontal axis: a hidden focused item is not in its own row's +// visible list, so its position could not be found. +func TestMoveHorizontalEscapesAHiddenFocusedItem(t *testing.T) { + items := grid() + items[4].Visible = false // middle of row 2; focus sits on it + + got := MoveHorizontal(items, 4, 1) + if !items[got].Visible { + t.Errorf("landed on hidden index %d", got) + } + if items[got].Row != 2 { + t.Errorf("left row 2 (landed on row %d); horizontal movement should stay in the row", items[got].Row) + } +} + +// Nothing visible at all: every function must return the index untouched rather +// than escaping to -1 and having the caller index with it. +func TestNavigationWithNothingVisibleStaysPut(t *testing.T) { + items := grid() + for i := range items { + items[i].Visible = false + } + for name, fn := range map[string]func([]Item, int, int) int{ + "MoveVertical": MoveVertical, + "MoveHorizontal": MoveHorizontal, + "JumpSection": JumpSection, + } { + if got := fn(items, 3, 1); got != 3 { + t.Errorf("%s with nothing visible = %d, want 3 (unchanged)", name, got) + } + } +} + +func TestMoveHorizontalWalksAndWrapsWithinARow(t *testing.T) { + items := grid() + // Row 2 holds indices 3,4,5 at columns 0,1,2. + idx := 3 + for _, want := range []int{4, 5, 3} { + idx = MoveHorizontal(items, idx, 1) + if idx != want { + t.Fatalf("right = %d, want %d", idx, want) + } + } + if got := MoveHorizontal(items, 3, -1); got != 5 { + t.Errorf("left from the first column = %d, want 5 (wrap)", got) + } +} + +func TestMoveHorizontalOnASingleItemRowStaysPut(t *testing.T) { + items := grid() + if got := MoveHorizontal(items, 2, 1); got != 2 { + t.Errorf("MoveHorizontal on a single-item row = %d, want 2", got) + } +} + +func TestMoveHorizontalSkipsHiddenItems(t *testing.T) { + items := grid() + items[4].Visible = false // middle of row 2 + if got := MoveHorizontal(items, 3, 1); got != 5 { + t.Errorf("right past a hidden item = %d, want 5", got) + } +} + +// Column order, not slice order, decides horizontal movement — the focus lists are +// hand-built and need not be sorted. +func TestMoveHorizontalUsesColumnOrderNotSliceOrder(t *testing.T) { + items := []Item{ + {Row: 0, Col: 2, Visible: true}, + {Row: 0, Col: 0, Visible: true}, + {Row: 0, Col: 1, Visible: true}, + } + if got := MoveHorizontal(items, 1, 1); got != 2 { + t.Errorf("from col 0 right = index %d (col %d), want index 2 (col 1)", got, items[got].Col) + } + if got := MoveHorizontal(items, 2, 1); got != 0 { + t.Errorf("from col 1 right = index %d (col %d), want index 0 (col 2)", got, items[got].Col) + } +} + +func TestSectionsAreInVisualOrder(t *testing.T) { + got := Sections(grid()) + want := []string{"tabs", "mode", "battery"} + if len(got) != len(want) { + t.Fatalf("Sections = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("Sections = %v, want %v", got, want) + } + } +} + +func TestSectionsOmitsFullyHiddenSections(t *testing.T) { + items := grid() + for i := range items { + if items[i].Section == "mode" { + items[i].Visible = false + } + } + for _, s := range Sections(items) { + if s == "mode" { + t.Error("Sections included a fully hidden section") + } + } +} + +func TestJumpSectionWalksAndWraps(t *testing.T) { + items := grid() + got := JumpSection(items, 0, 1) // tabs -> mode + if items[got].Section != "mode" { + t.Errorf("landed in %q, want mode", items[got].Section) + } + got = JumpSection(items, got, 1) // mode -> battery + if items[got].Section != "battery" { + t.Errorf("landed in %q, want battery", items[got].Section) + } + got = JumpSection(items, got, 1) // battery -> tabs (wrap) + if items[got].Section != "tabs" { + t.Errorf("landed in %q, want tabs (wrap)", items[got].Section) + } + got = JumpSection(items, 0, -1) // tabs -> battery (wrap backwards) + if items[got].Section != "battery" { + t.Errorf("landed in %q, want battery (reverse wrap)", items[got].Section) + } +} + +func TestJumpSectionLandsOnTheFirstVisibleItemOfTheSection(t *testing.T) { + items := grid() + items[3].Visible = false // first item of row 2 in section "mode" + got := JumpSection(items, 0, 1) + if got != 2 { + t.Errorf("JumpSection = %d, want 2 (first visible item of mode)", got) + } + if !items[got].Visible { + t.Errorf("landed on hidden index %d", got) + } +} + +func TestJumpSectionWithOneSectionStaysPut(t *testing.T) { + items := []Item{ + {Row: 0, Col: 0, Section: "only", Visible: true}, + {Row: 1, Col: 0, Section: "only", Visible: true}, + } + if got := JumpSection(items, 0, 1); got != 0 { + t.Errorf("JumpSection with one section = %d, want 0", got) + } +} + +// The invariant that matters most: no sequence of presses may leave focus on a +// hidden item or outside the slice. Walks a mixed-visibility grid through a long +// pseudo-random-but-deterministic sequence of moves. +func TestNavigationNeverLandsOnAHiddenOrInvalidItem(t *testing.T) { + items := grid() + items[1].Visible = false + items[4].Visible = false + + moves := []struct { + name string + fn func([]Item, int, int) int + dir int + }{ + {"down", MoveVertical, 1}, + {"up", MoveVertical, -1}, + {"right", MoveHorizontal, 1}, + {"left", MoveHorizontal, -1}, + {"nextSection", JumpSection, 1}, + {"prevSection", JumpSection, -1}, + } + + idx := FirstVisible(items) + if idx < 0 { + t.Fatal("no visible item to start from") + } + for step := 0; step < 500; step++ { + m := moves[(step*7+step/3)%len(moves)] + idx = m.fn(items, idx, m.dir) + if idx < 0 || idx >= len(items) { + t.Fatalf("step %d (%s): idx %d out of range", step, m.name, idx) + } + if !items[idx].Visible { + t.Fatalf("step %d (%s): landed on hidden index %d", step, m.name, idx) + } + } +} + +// TestMoveVerticalTieBreakIsColumnNotSliceOrder pins the rule stated on +// MoveVertical: with two equidistant candidates the lower column wins. +// +// It used to fall out of slice order — the first equidistant item found — which +// matched only because every focus list in the drawer happens to be appended left +// to right. The rule is what makes down-then-up return to where you started, so +// it has to hold for any input order, not just the convenient one. +func TestMoveVerticalTieBreakIsColumnNotSliceOrder(t *testing.T) { + // Cursor at col 1; the target row has cols 0 and 2, both one away. + orders := map[string][]Item{ + "target row listed high column first": { + {Row: 0, Col: 1, Visible: true}, + {Row: 1, Col: 2, Visible: true}, + {Row: 1, Col: 0, Visible: true}, + }, + "target row listed low column first": { + {Row: 0, Col: 1, Visible: true}, + {Row: 1, Col: 0, Visible: true}, + {Row: 1, Col: 2, Visible: true}, + }, + } + for name, items := range orders { + t.Run(name, func(t *testing.T) { + got := MoveVertical(items, 0, 1) + if items[got].Col != 0 { + t.Errorf("tie went to col %d, want the lower column 0", items[got].Col) + } + }) + } +} + +// TestMoveVerticalRoundTrips is the property the tie-break exists to provide: +// moving down and back up returns to the starting item, whatever order the rows +// were appended in. +func TestMoveVerticalRoundTrips(t *testing.T) { + items := []Item{ + {Row: 0, Col: 2, Visible: true}, // 0 + {Row: 0, Col: 0, Visible: true}, // 1 + {Row: 1, Col: 2, Visible: true}, // 2 + {Row: 1, Col: 0, Visible: true}, // 3 + } + for start := range items { + down := MoveVertical(items, start, 1) + back := MoveVertical(items, down, -1) + if items[back].Col != items[start].Col { + t.Errorf("from idx %d (col %d): down to col %d, up to col %d — not a round trip", + start, items[start].Col, items[down].Col, items[back].Col) + } + } +} diff --git a/internal/gui/backend.go b/internal/gui/backend.go index bd95a71..84f8a57 100644 --- a/internal/gui/backend.go +++ b/internal/gui/backend.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package gui import "github.com/diamondburned/gotk4/pkg/gtk/v4" @@ -23,4 +26,12 @@ type Backend interface { // Hide hides the drawer (animation, atom toggle, etc). Hide() + + // Scale returns the factor the drawer's CSS pixel sizes are multiplied by. + // Layer-shell returns 1.0 — GTK handles scaling there. Gamescope scales its + // own CSS because GDK_SCALE would be applied twice, and anything drawn + // directly rather than styled has to apply the same factor by hand or it + // stays at its 1x size while everything around it grows. Valid after + // Configure has realized the window. + Scale() float64 } diff --git a/internal/gui/color.go b/internal/gui/color.go index 59735e0..2a3a801 100644 --- a/internal/gui/color.go +++ b/internal/gui/color.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package gui // color.go — color input widget: swatch + preset buttons + custom button. @@ -6,9 +9,10 @@ package gui import ( "fmt" - "math" - "strings" + "log/slog" + "github.com/dahui/z13gui/internal/colorconv" + "github.com/dahui/z13gui/internal/lighting" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) @@ -38,7 +42,12 @@ type colorInput struct { // newColorInput creates a color input widget with swatch, preset buttons, // and a Custom button that navigates to the HSL color picker view. func (w *Window) newColorInput(initialHex, swatchName, label string) *colorInput { - ci := &colorInput{hex: strings.ToUpper(initialHex), label: label} + hex, ok := colorconv.Normalize(initialHex) + if !ok { + slog.Warn("color input created with an unparseable default", "hex", initialHex) + hex = lighting.DefaultColor1 + } + ci := &colorInput{hex: hex, label: label} // Current-color swatch (non-interactive colored square). ci.swatch = gtk.NewBox(gtk.OrientationHorizontal, 0) @@ -112,12 +121,22 @@ func (w *Window) showColorView(ci *colorInput) { } w.editingColor = ci w.colorViewTitle.SetLabel(ci.label) - h, s, l := hexToHSL(ci.hex) - w.syncing = true - w.colorHue.SetValue(h) - w.colorSat.SetValue(s) - w.colorLit.SetValue(l) - w.syncing = false + // Defensive: hex is normalized on ingest in syncLightingSection, so a failure + // here means something skipped that path. Leaving the sliders alone beats + // snapping them to black. + if h, sat, l, ok := colorconv.HexToHSL(ci.hex); ok { + // Save and restore rather than assign false: everywhere else that suppresses + // signals does the same, and a bare assignment here would clear the flag out + // from under an enclosing sync if this were ever reached from one. + prev := w.syncing + w.syncing = true + w.colorHue.SetValue(h) + w.colorSat.SetValue(sat) + w.colorLit.SetValue(l) + w.syncing = prev + } else { + slog.Warn("color picker opened with an unparseable color", "hex", ci.hex) + } w.updateColorPreview() w.viewStack.SetVisibleChildName("color") w.swapFocusList(w.colorFocusItems) @@ -132,7 +151,7 @@ func (w *Window) onHSLChanged() { h := w.colorHue.Value() s := w.colorSat.Value() l := w.colorLit.Value() - hex := hslToHex(h, s, l) + hex := colorconv.HSLToHex(h, s, l) w.editingColor.hex = hex w.updateSwatches() w.updateColorPreview() @@ -147,13 +166,18 @@ func (w *Window) colorPickerPresetClicked(hex string) { w.editingColor.hex = hex w.updateSwatches() w.sendApply() - // Update HSL sliders to reflect the preset. - h, s, l := hexToHSL(hex) - w.syncing = true - w.colorHue.SetValue(h) - w.colorSat.SetValue(s) - w.colorLit.SetValue(l) - w.syncing = false + // Update HSL sliders to reflect the preset. presetColors are compile-time + // constants, so a failure here is a programming error, not bad input. + if h, s, l, ok := colorconv.HexToHSL(hex); ok { + prev := w.syncing + w.syncing = true + w.colorHue.SetValue(h) + w.colorSat.SetValue(s) + w.colorLit.SetValue(l) + w.syncing = prev + } else { + slog.Error("preset color is not parseable", "hex", hex) + } w.updateColorPreview() } @@ -170,82 +194,3 @@ func (w *Window) updateColorPreview() { w.colorHexLabel.SetLabel("#" + hex) } } - -// hexToHSL converts a 6-digit hex string (e.g. "FF6600") to HSL components. -// Returns H in [0,360], S in [0,100], L in [0,100]. -func hexToHSL(hex string) (h, s, l float64) { - var ri, gi, bi uint8 - _, _ = fmt.Sscanf(hex, "%02X%02X%02X", &ri, &gi, &bi) - r, g, b := float64(ri)/255, float64(gi)/255, float64(bi)/255 - - maxC := math.Max(r, math.Max(g, b)) - minC := math.Min(r, math.Min(g, b)) - l = (maxC + minC) / 2 - - if maxC == minC { - return 0, 0, l * 100 - } - d := maxC - minC - if l > 0.5 { - s = d / (2 - maxC - minC) - } else { - s = d / (maxC + minC) - } - switch maxC { - case r: - h = (g - b) / d - if g < b { - h += 6 - } - case g: - h = (b-r)/d + 2 - case b: - h = (r-g)/d + 4 - } - return h * 60, s * 100, l * 100 -} - -// hslToHex converts HSL components to a 6-digit hex string. -// H in [0,360], S in [0,100], L in [0,100]. -func hslToHex(h, s, l float64) string { - h, s, l = h/360, s/100, l/100 - if s == 0 { - v := int(math.Round(l * 255)) - return fmt.Sprintf("%02X%02X%02X", v, v, v) - } - var q float64 - if l < 0.5 { - q = l * (1 + s) - } else { - q = l + s - l*s - } - p := 2*l - q - r := hueToRGB(p, q, h+1.0/3.0) - g := hueToRGB(p, q, h) - b := hueToRGB(p, q, h-1.0/3.0) - return fmt.Sprintf("%02X%02X%02X", - int(math.Round(r*255)), - int(math.Round(g*255)), - int(math.Round(b*255)), - ) -} - -// hueToRGB is a helper for HSL→RGB conversion. -func hueToRGB(p, q, t float64) float64 { - if t < 0 { - t++ - } - if t > 1 { - t-- - } - switch { - case t < 1.0/6.0: - return p + (q-p)*6*t - case t < 1.0/2.0: - return q - case t < 2.0/3.0: - return p + (q-p)*(2.0/3.0-t)*6 - default: - return p - } -} diff --git a/internal/gui/controls.go b/internal/gui/controls.go index 9072c80..ed7e7cd 100644 --- a/internal/gui/controls.go +++ b/internal/gui/controls.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package gui // controls.go — builds the entire drawer widget tree, theme picker view, @@ -8,10 +11,8 @@ import ( "fmt" "strings" - "github.com/dahui/z13ctl/api" "github.com/dahui/z13gui/internal/theme" "github.com/diamondburned/gotk4/pkg/gdk/v4" - "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) @@ -110,6 +111,10 @@ func (w *Window) buildContent() gtk.Widgetter { w.viewStack.SetVisibleChildName("main") outer.Append(w.viewStack) + // Error bar sits outside the stack so a failure raised in any view stays + // visible, including after a view switch. + outer.Append(w.buildErrorBar()) + outer.Append(w.buildBottomBar()) w.buildMainFocusList() @@ -237,6 +242,9 @@ func (w *Window) appendThemeChoices(box *gtk.Box) { } btn.ConnectToggled(func() { if btn.Active() { + // Selecting the theme itself means its default accent, so no dot is + // the active one. A dot click re-marks itself afterwards. + w.setActiveAccentDot(nil) w.applyTheme(id, "") } }) @@ -262,6 +270,7 @@ func (w *Window) appendThemeChoices(box *gtk.Box) { customBtn.SetActive(true) customBtn.ConnectToggled(func() { if customBtn.Active() { + w.setActiveAccentDot(nil) w.applyCustomAccent("") } }) @@ -279,6 +288,26 @@ func (w *Window) appendThemeChoices(box *gtk.Box) { } } +// setActiveAccentDot moves the .accent-dot-active marker to active, clearing it +// from every other dot across every theme. Pass nil to clear it entirely. +// +// The marker used to be applied once, while the theme view was being built, from +// the config file's saved accent — and the view is built lazily exactly once and +// then kept. So picking a different accent left the marker where it was, and +// switching theme left the previous theme's dot marked. Nothing showed which +// accent was actually in force. +func (w *Window) setActiveAccentDot(active *gtk.Button) { + for _, row := range w.themeDots { + for _, dot := range row { + if dot != nil && dot == active { + dot.AddCSSClass("accent-dot-active") + } else if dot != nil { + dot.RemoveCSSClass("accent-dot-active") + } + } + } +} + // appendAccentDots builds the "Accent Color" label and dot button grid for the // given accent list and appends both to box. Returns the dot buttons for use // in the focus list. isActive reports whether a dot should be marked active; @@ -316,7 +345,12 @@ func (w *Window) appendAccentDots(box *gtk.Box, accents []theme.Accent, isActive provider.LoadFromString("button.color-preset { background: " + ac.Hex + "; }") dot.StyleContext().AddProvider(provider, gtk.STYLE_PROVIDER_PRIORITY_USER+20) //nolint:staticcheck // per-widget dynamic color; no style-class alternative for unique hex backgrounds dot.SetTooltipText(ac.Name) - dot.ConnectClicked(func() { onClick(ac) }) + dot.ConnectClicked(func() { + // onClick first: it may activate this theme's radio button, whose + // toggled handler clears every dot. Marking afterwards survives that. + onClick(ac) + w.setActiveAccentDot(dot) + }) dots = append(dots, dot) row.Append(dot) } @@ -627,18 +661,9 @@ func (w *Window) buildProfileSection() *gtk.Box { w.showCustomView() } else { setActiveButton(w.profileBtns, prof) + // sendProfileSet refreshes state itself once the daemon has + // applied the profile; fetching it here in parallel would race. w.sendProfileSet(prof) - go func() { - ok, state, err := api.SendGetState() - if ok && err == nil { - glib.IdleAdd(func() { - w.state = state - w.syncing = true - w.syncCustomView() - w.syncing = false - }) - } - }() } }) w.profileBtns[prof] = btn @@ -827,6 +852,7 @@ func (w *Window) buildMainFocusList() { }) } + items = append(items, w.errBarFocusItem()) w.mainFocusItems = items } @@ -868,6 +894,7 @@ func (w *Window) buildThemeFocusList() { } } + items = append(items, w.errBarFocusItem()) w.themeFocusItems = items } @@ -906,5 +933,6 @@ func (w *Window) buildColorFocusList() { }) } + items = append(items, w.errBarFocusItem()) w.colorFocusItems = items } diff --git a/internal/gui/errbar.go b/internal/gui/errbar.go new file mode 100644 index 0000000..2b1966e --- /dev/null +++ b/internal/gui/errbar.go @@ -0,0 +1,137 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package gui + +// errbar.go — the drawer's single user-facing error surface. +// +// Every daemon call in this package runs on a background goroutine and used to +// drop its error into slog and return, which made a failed operation look like a +// button that did nothing (see z13ctl issue #14, where "Save TDP" was silently +// rejected with "permission denied" for weeks). The bar lives outside the view +// stack, between it and the bottom bar, so one instance serves the main, custom, +// theme and color views in both the layer-shell and gamescope backends. +// +// Deliberately a plain Box + Label + Button: popovers are not composited under +// gamescope, and gtk.Revealer smears during the slide animation. Buttons use a +// CAPTURE-phase gesture internally, so touch works in gamescope without the +// addTouchActivate workaround needed for CheckButton and Switch. + +import ( + "log/slog" + + "github.com/diamondburned/gotk4/pkg/glib/v2" + "github.com/diamondburned/gotk4/pkg/gtk/v4" + "github.com/diamondburned/gotk4/pkg/pango" +) + +// errLabelMaxChars caps the error label's natural width request. The drawer is +// drawerWidth (320px) wide; at the 10px .error-text size this is comfortably +// inside the content area, so the bar never drives the drawer wider. +const errLabelMaxChars = 34 + +// buildErrorBar returns the hidden-by-default error strip. It is shown by +// reportError and hidden by clearError. +func (w *Window) buildErrorBar() *gtk.Box { + bar := gtk.NewBox(gtk.OrientationHorizontal, 4) + bar.AddCSSClass("error-bar") + bar.SetVisible(false) + + w.errLabel = gtk.NewLabel("") + w.errLabel.SetXAlign(0) + w.errLabel.SetHExpand(true) + w.errLabel.AddCSSClass("error-text") + // Daemon messages embed sysfs paths, which are single unbreakable tokens. + // A plain wrapping label reports the longest such token as its minimum width, + // which widens the whole drawer to fit + // "/sys/devices/platform/asus-nb-wmi/ppt_pl1_spl". WrapWordChar lets pango + // break mid-token, and MaxWidthChars caps the natural width so the label + // wraps into the drawer instead of stretching it. + w.errLabel.SetWrap(true) + w.errLabel.SetWrapMode(pango.WrapWordChar) + w.errLabel.SetMaxWidthChars(errLabelMaxChars) + bar.Append(w.errLabel) + + dismiss := gtk.NewButton() + dismiss.SetIconName("window-close-symbolic") + dismiss.SetTooltipText("Dismiss") + dismiss.AddCSSClass("error-dismiss") + dismiss.SetVAlign(gtk.AlignStart) + dismiss.ConnectClicked(func() { w.clearError() }) + bar.Append(dismiss) + + w.errBar = bar + w.errDismissBtn = dismiss + return bar +} + +// errBarRow places the error bar after every other row in every view's focus +// grid. The bar is appended outside the view stack, so it has no natural row +// number shared with the view in front of it; a value beyond any real row keeps +// it last wherever it appears. +const errBarRow = 10000 + +// errBarFocusItem returns the gamepad entry for the dismiss button, for appending +// to each view's focus list. +// +// Without it a controller could not dismiss an error at all — the only ways out +// were to close the drawer or to complete an operation successfully, which is +// exactly what a user staring at a failure is unsure how to do. It is only +// navigable while the bar is showing. +func (w *Window) errBarFocusItem() focusItem { + return focusItem{ + widget: w.errDismissBtn, row: errBarRow, col: 0, + section: "error", + isVisible: func() bool { return w.errBar != nil && w.errBar.IsVisible() }, + onActivate: func() { w.clearError() }, + } +} + +// reportError shows err in the error bar and logs it. Safe to call from any +// goroutine: every daemon call in this package runs in its own goroutine, so the +// widget work is marshalled onto the GTK main thread. +// +// op should name the operation the way the user thinks of it ("Save TDP"), not +// the function that failed. +func (w *Window) reportError(op string, err error) { + if err == nil { + return + } + slog.Warn("operation failed", "op", op, "err", err) + msg := op + ": " + err.Error() + glib.IdleAdd(func() { + if w.errBar == nil || w.errLabel == nil { + return + } + // A call still in flight when the drawer closes lands here afterwards, and + // showing the bar then means it is already up the next time the drawer + // opens — the stale failure hide()'s clearError exists to prevent. The + // journal still has it. + if !w.visible.Load() { + slog.Debug("error suppressed: drawer already closed", "op", op) + return + } + // Most recent error wins; the bar shows one message at a time. + w.errLabel.SetLabel(msg) + w.errBar.SetVisible(true) + }) +} + +// clearError hides the error bar. Must be called from the GTK main thread. +// Called on each successful operation and from hide(), so a stale failure does +// not greet the user the next time the drawer opens. +func (w *Window) clearError() { + if w.errBar == nil { + return + } + w.errBar.SetVisible(false) + if w.errLabel != nil { + w.errLabel.SetLabel("") + } +} + +// clearErrorAsync is clearError for callers on a background goroutine — the +// success path of the same calls that use reportError. +func (w *Window) clearErrorAsync() { + glib.IdleAdd(func() { w.clearError() }) +} diff --git a/internal/gui/focus.go b/internal/gui/focus.go index f50efaf..b9685d3 100644 --- a/internal/gui/focus.go +++ b/internal/gui/focus.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package gui // focus.go — 2D grid gamepad focus navigation with modal slider editing. @@ -12,22 +15,22 @@ package gui import ( "log/slog" - "sort" + "github.com/dahui/z13gui/internal/focusgrid" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) // focusItem represents a single gamepad-navigable element. type focusItem struct { - widget gtk.Widgetter // widget to highlight with .gamepad-focus - row int // visual row number - col int // column within row - section string // section name for shoulder-button jumping - isVisible func() bool // false if parent section is hidden; nil = always visible - onActivate func() // A button: toggle/activate (non-editable items) - editable bool // true for sliders — A enters edit mode instead of activating - onLeft func() // D-pad left while editing: decrease value - onRight func() // D-pad right while editing: increase value + widget gtk.Widgetter // widget to highlight with .gamepad-focus + row int // visual row number + col int // column within row + section string // section name for shoulder-button jumping + isVisible func() bool // false if parent section is hidden; nil = always visible + onActivate func() // A button: toggle/activate (non-editable items) + editable bool // true for sliders — A enters edit mode instead of activating + onLeft func() // D-pad left while editing: decrease value + onRight func() // D-pad right while editing: increase value getValue func() float64 // read current value (for cancel/restore) setValue func(float64) // restore value on cancel } @@ -40,161 +43,46 @@ func (fi *focusItem) visible() bool { return gtk.BaseWidget(fi.widget).IsVisible() } -// visibleRows returns sorted unique row numbers that have at least one visible item. -func (w *Window) visibleRows() []int { - seen := make(map[int]bool) - var rows []int +// gridSnapshot flattens focusItems into the pure representation focusgrid works +// on, evaluating each item's visibility exactly once so a single press sees a +// consistent view even if a widget changes underneath it. +func (w *Window) gridSnapshot() []focusgrid.Item { + items := make([]focusgrid.Item, len(w.focusItems)) for i := range w.focusItems { fi := &w.focusItems[i] - if fi.visible() && !seen[fi.row] { - seen[fi.row] = true - rows = append(rows, fi.row) + items[i] = focusgrid.Item{ + Row: fi.row, + Col: fi.col, + Section: fi.section, + Visible: fi.visible(), } } - sort.Ints(rows) - return rows + return items } -// moveVertical moves focus to the nearest visible item in the next (dir=+1) or -// previous (dir=-1) row. Preserves column position where possible. -func (w *Window) moveVertical(dir int) { - if len(w.focusItems) == 0 { - return - } - rows := w.visibleRows() - if len(rows) == 0 { - return - } - current := w.focusItems[w.focusIdx] - targetCol := current.col - - // Find current row's position in visible rows. - curPos := -1 - for i, r := range rows { - if r == current.row { - curPos = i - break - } - } - if curPos == -1 { - return - } - - // Step to next/prev row (wrapping). - nextPos := (curPos + dir + len(rows)) % len(rows) - if nextPos == curPos { - return // only one visible row - } - targetRow := rows[nextPos] - - // Find the item in targetRow with the closest column to targetCol. - best := -1 - bestDist := 1<<31 - 1 - for i := range w.focusItems { - fi := &w.focusItems[i] - if fi.row == targetRow && fi.visible() { - d := targetCol - fi.col - if d < 0 { - d = -d - } - if d < bestDist { - bestDist = d - best = i - } - } - } - if best >= 0 { - w.setFocusIdx(best) +// navigate applies a focusgrid move. The grid functions return the index +// unchanged when a move is impossible and tolerate an out-of-range index, so +// there is nothing to guard here. +func (w *Window) navigate(move func([]focusgrid.Item, int, int) int, dir int) { + if next := move(w.gridSnapshot(), w.focusIdx, dir); next != w.focusIdx { + w.setFocusIdx(next) } } -// moveHorizontal moves focus to the next (dir=+1) or previous (dir=-1) visible -// item within the same row. Wraps at row edges. -func (w *Window) moveHorizontal(dir int) { - if len(w.focusItems) == 0 { - return - } - current := w.focusItems[w.focusIdx] - - // Collect visible items in the same row, sorted by column. - var rowItems []int - for i := range w.focusItems { - fi := &w.focusItems[i] - if fi.row == current.row && fi.visible() { - rowItems = append(rowItems, i) - } - } - if len(rowItems) <= 1 { - return // single-item row, no horizontal movement - } - sort.Slice(rowItems, func(a, b int) bool { - return w.focusItems[rowItems[a]].col < w.focusItems[rowItems[b]].col - }) - - // Find current position within the row. - pos := -1 - for i, idx := range rowItems { - if idx == w.focusIdx { - pos = i - break - } - } - if pos == -1 { - return - } - - next := (pos + dir + len(rowItems)) % len(rowItems) - w.setFocusIdx(rowItems[next]) -} - -// jumpSection jumps to the first visible item of the next (dir=+1) or -// previous (dir=-1) section. -func (w *Window) jumpSection(dir int) { - if len(w.focusItems) == 0 { - return - } - current := w.focusItems[w.focusIdx].section - - // Collect sections in row order. - var sections []string - seen := make(map[string]bool) - rows := w.visibleRows() - for _, r := range rows { - for i := range w.focusItems { - fi := &w.focusItems[i] - if fi.row == r && fi.visible() && !seen[fi.section] { - seen[fi.section] = true - sections = append(sections, fi.section) - } - } - } - if len(sections) <= 1 { - return - } +// moveVertical moves focus to the nearest visible item in the next (dir=+1) or +// previous (dir=-1) row, preserving column where possible. +func (w *Window) moveVertical(dir int) { w.navigate(focusgrid.MoveVertical, dir) } - // Find current section position. - curPos := -1 - for i, s := range sections { - if s == current { - curPos = i - break - } - } - if curPos == -1 { - return - } +// moveHorizontal moves focus within the current row, wrapping at its edges. +func (w *Window) moveHorizontal(dir int) { w.navigate(focusgrid.MoveHorizontal, dir) } - // Step to next/prev section (wrapping). - nextPos := (curPos + dir + len(sections)) % len(sections) - target := sections[nextPos] +// jumpSection jumps to the first visible item of the adjacent section. +func (w *Window) jumpSection(dir int) { w.navigate(focusgrid.JumpSection, dir) } - // Find first visible item in the target section. - for i := range w.focusItems { - fi := &w.focusItems[i] - if fi.section == target && fi.visible() { - w.setFocusIdx(i) - return - } +// focusFirstVisible moves focus to the first visible item, if there is one. +func (w *Window) focusFirstVisible() { + if idx := focusgrid.FirstVisible(w.gridSnapshot()); idx >= 0 { + w.setFocusIdx(idx) } } @@ -277,12 +165,7 @@ func (w *Window) showGamepadFocus() { return } w.gamepadActive = true - for i := range w.focusItems { - if w.focusItems[i].visible() { - w.setFocusIdx(i) - return - } - } + w.focusFirstVisible() } // hideGamepadFocus removes the gamepad focus indicator (e.g. on mouse movement). @@ -310,12 +193,7 @@ func (w *Window) swapFocusList(items []focusItem) { w.focusItems = items w.focusIdx = 0 if w.gamepadActive { - for i := range w.focusItems { - if w.focusItems[i].visible() { - w.setFocusIdx(i) - return - } - } + w.focusFirstVisible() } } diff --git a/internal/gui/fonts/LICENSE-Inter.txt b/internal/gui/fonts/LICENSE-Inter.txt new file mode 100644 index 0000000..39c2feb --- /dev/null +++ b/internal/gui/fonts/LICENSE-Inter.txt @@ -0,0 +1,88 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/internal/gui/fonts/font.go b/internal/gui/fonts/font.go index 4f102d3..3f72e87 100644 --- a/internal/gui/fonts/font.go +++ b/internal/gui/fonts/font.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + // Package fonts embeds the Inter typeface and registers it with fontconfig // at application startup so the drawer renders identically across desktop // (KDE/Wayland) and gamescope (Steam Gaming Mode) environments. diff --git a/internal/gui/gamepad/gamepad.go b/internal/gui/gamepad/gamepad.go index 1138dde..64c7c0d 100644 --- a/internal/gui/gamepad/gamepad.go +++ b/internal/gui/gamepad/gamepad.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + // Package gamepad reads Linux evdev gamepad events and dispatches normalized // actions to the GUI. It scans /dev/input/event* for gamepad devices, reads // events in background goroutines, and translates them into Action values. @@ -24,6 +27,7 @@ import ( "sync" "time" + "github.com/dahui/z13gui/internal/keyrepeat" evdev "github.com/holoplot/go-evdev" ) @@ -49,9 +53,9 @@ type Handler func(Action) type deviceClass int const ( - deviceIgnore deviceClass = iota // not gamepad-related; skip - deviceGamepad // full gamepad: read events + EVIOCGRAB - deviceGrabOnly // related device (e.g. PS touchpad): EVIOCGRAB only + deviceIgnore deviceClass = iota // not gamepad-related; skip + deviceGamepad // full gamepad: read events + EVIOCGRAB + deviceGrabOnly // related device (e.g. PS touchpad): EVIOCGRAB only ) // gamepadButtons are evdev button codes that identify a device as a gamepad. @@ -82,6 +86,7 @@ type Reader struct { devices map[string]*evdev.InputDevice // gamepad devices: read events + grab grabOnly map[string]*evdev.InputDevice // related devices: grab only (e.g. PS touchpad) grabbed bool // true while overlay is visible (exclusive grab) + grabSeq uint64 // highest SetGrabbed seq applied; rejects stale requests stop chan struct{} } @@ -123,48 +128,53 @@ func (r *Reader) Stop() { } } -// GrabAll acquires exclusive access (EVIOCGRAB) on all tracked devices -// so events are not delivered to other readers (e.g. the background game). -// New devices discovered while grabbed are auto-grabbed in tryOpen. -func (r *Reader) GrabAll() { +// SetGrabbed acquires (grab=true) or releases (grab=false) exclusive access +// (EVIOCGRAB) on every tracked device, so events either reach only the drawer or +// go back to the desktop and any running game. New devices discovered while +// grabbed are auto-grabbed in tryOpen. +// +// seq orders requests that overlap. Both callers run on their own goroutine — +// the socket work must not block the GTK thread — and the gamescope hide path +// delays its release so the dismiss button's release event is consumed first, so +// they can and do arrive out of order. Applying a stale one is not cosmetic: an +// ungrab landing after a re-show hands the game the same D-pad presses being used +// to navigate the drawer, and a grab landing after a hide leaves every controller +// exclusively grabbed with nothing on screen — no input reaches the game at all +// until the next full open/close cycle. +// +// The caller supplies a seq that increases with each show/hide from the GTK +// thread, which is the only place that knows the intended order. +func (r *Reader) SetGrabbed(seq uint64, grab bool) { r.mu.Lock() defer r.mu.Unlock() - r.grabbed = true - for path, dev := range r.devices { - if err := dev.Grab(); err != nil { - slog.Warn("gamepad: grab failed", "path", path, "err", err) - } else { - slog.Info("gamepad: grabbed", "path", path) - } + if seq <= r.grabSeq { + slog.Debug("gamepad: ignoring superseded grab request", + "seq", seq, "current", r.grabSeq, "grab", grab) + return } - for path, dev := range r.grabOnly { - if err := dev.Grab(); err != nil { - slog.Warn("gamepad: grab failed", "path", path, "err", err) + r.grabSeq = seq + r.grabbed = grab + + apply := func(path string, dev *evdev.InputDevice) { + var err error + failed, done := "ungrab failed", "ungrabbed" + if grab { + err = dev.Grab() + failed, done = "grab failed", "grabbed" } else { - slog.Info("gamepad: grabbed", "path", path) + err = dev.Ungrab() + } + if err != nil { + slog.Warn("gamepad: "+failed, "path", path, "err", err) + return } + slog.Info("gamepad: "+done, "path", path) } -} - -// UngrabAll releases exclusive access on all tracked devices, -// allowing the background game to receive events again. -func (r *Reader) UngrabAll() { - r.mu.Lock() - defer r.mu.Unlock() - r.grabbed = false for path, dev := range r.devices { - if err := dev.Ungrab(); err != nil { - slog.Warn("gamepad: ungrab failed", "path", path, "err", err) - } else { - slog.Info("gamepad: ungrabbed", "path", path) - } + apply(path, dev) } for path, dev := range r.grabOnly { - if err := dev.Ungrab(); err != nil { - slog.Warn("gamepad: ungrab failed", "path", path, "err", err) - } else { - slog.Info("gamepad: ungrabbed", "path", path) - } + apply(path, dev) } } @@ -300,35 +310,46 @@ func (r *Reader) readLoop(path string, dev *evdev.InputDevice) { slog.Info("gamepad: disconnected", "path", path) }() + // Auto-repeat for held directions. keyrepeat owns the "who holds the repeat" + // bookkeeping (tested there); this keeps only the timer. var repeatMu sync.Mutex var repeatTimer *time.Timer + var repeat keyrepeat.Tracker[Action] - stopRepeat := func() { + // stopRepeat cancels the repeat. With no arguments it stops whatever is + // active; given actions it stops only if the repeat belongs to one of them, + // so releasing one held direction leaves another still held repeating. + stopRepeat := func(only ...Action) { repeatMu.Lock() - if repeatTimer != nil { + defer repeatMu.Unlock() + if repeat.Stop(only...) && repeatTimer != nil { repeatTimer.Stop() repeatTimer = nil } - repeatMu.Unlock() } defer stopRepeat() startRepeat := func(a Action) { repeatMu.Lock() + defer repeatMu.Unlock() if repeatTimer != nil { repeatTimer.Stop() } + // gen retires any callback already in flight. Without it the previous + // direction's timer — which has fired and is waiting on this lock — re-armed + // itself and overwrote this timer, so the old direction repeated forever + // while the new one never started. + gen := repeat.Start(a) var tick func() tick = func() { r.emit(a) repeatMu.Lock() - if repeatTimer != nil { + if repeat.ReArm(gen) { repeatTimer = time.AfterFunc(repeatInterval, tick) } repeatMu.Unlock() } repeatTimer = time.AfterFunc(repeatInitial, tick) - repeatMu.Unlock() } for { @@ -356,12 +377,17 @@ func (r *Reader) readLoop(path string, dev *evdev.InputDevice) { case 0: // key up if a, ok := buttonToAction(ev.Code); ok { if isDirectional(a) { - stopRepeat() + // Only this direction: a button-style D-pad reports each + // direction separately, so an unqualified stop here cancelled + // a different direction the user was still holding. + stopRepeat(a) } } } case evdev.EV_ABS: + // A hat axis returning to centre says nothing about the other axis, so + // each centre event stops only the two directions on its own axis. switch ev.Code { case evdev.ABS_HAT0Y: switch { @@ -372,7 +398,7 @@ func (r *Reader) readLoop(path string, dev *evdev.InputDevice) { r.emit(ActionDown) startRepeat(ActionDown) default: - stopRepeat() + stopRepeat(ActionUp, ActionDown) } case evdev.ABS_HAT0X: switch { @@ -383,7 +409,7 @@ func (r *Reader) readLoop(path string, dev *evdev.InputDevice) { r.emit(ActionRight) startRepeat(ActionRight) default: - stopRepeat() + stopRepeat(ActionLeft, ActionRight) } } } diff --git a/internal/gui/gamepad/hidblocker/blocker.bpf.c b/internal/gui/gamepad/hidblocker/blocker.bpf.c index 4786ad1..1056441 100644 --- a/internal/gui/gamepad/hidblocker/blocker.bpf.c +++ b/internal/gui/gamepad/hidblocker/blocker.bpf.c @@ -1,5 +1,20 @@ //go:build ignore +// Deliberately carries no SPDX header, unlike every other source file here. +// +// The LICENSE[] section at the bottom of this file declares "GPL" to the kernel. +// That is not a copyright statement — it is the string the kernel's BPF verifier +// reads to decide whether the program may call GPL-only helpers, and it is +// load-bearing: BPF_CORE_READ below expands to bpf_probe_read_kernel, which is +// gpl_only, so the program fails to load if it declares anything else. +// +// Stamping an Apache-2.0 header here would sit awkwardly next to that, since +// Apache-2.0 and GPL-2.0 are not one-way compatible, and the honest answer needs +// a decision rather than a default: the usual convention for BPF sources is to +// license them "GPL-2.0 OR BSD-3-Clause" so the kernel declaration and the +// source licence agree. Left unstamped pending that call — the repository +// LICENSE still applies to it in the meantime. + #include "vmlinux.h" #include #include diff --git a/internal/gui/gamepad/hidblocker/gen.go b/internal/gui/gamepad/hidblocker/gen.go index 30ec4b9..b3df081 100644 --- a/internal/gui/gamepad/hidblocker/gen.go +++ b/internal/gui/gamepad/hidblocker/gen.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package hidblocker //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -target amd64 blocker blocker.bpf.c -- -I. -Wall -O2 -g -Wno-address-of-packed-member diff --git a/internal/gui/gamepad/hidblocker/hidblocker.go b/internal/gui/gamepad/hidblocker/hidblocker.go index 2095338..047faf9 100644 --- a/internal/gui/gamepad/hidblocker/hidblocker.go +++ b/internal/gui/gamepad/hidblocker/hidblocker.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package hidblocker import ( diff --git a/internal/gui/gamepad/hidblocker/hidblocker_test.go b/internal/gui/gamepad/hidblocker/hidblocker_test.go index 6b5d6c0..06e7b23 100644 --- a/internal/gui/gamepad/hidblocker/hidblocker_test.go +++ b/internal/gui/gamepad/hidblocker/hidblocker_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package hidblocker import ( diff --git a/internal/gui/gamepad/steam.go b/internal/gui/gamepad/steam.go index 7784b24..bfcd795 100644 --- a/internal/gui/gamepad/steam.go +++ b/internal/gui/gamepad/steam.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package gamepad import ( diff --git a/internal/gui/gamescope/gamescope.go b/internal/gui/gamescope/gamescope.go index 134ff89..cc43b9c 100644 --- a/internal/gui/gamescope/gamescope.go +++ b/internal/gui/gamescope/gamescope.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + // Package gamescope implements the X11 overlay display backend for gamescope // (Steam Gaming Mode). It sets X11 atoms on the window so gamescope composites // it as an external overlay above the running game. @@ -53,19 +56,16 @@ import ( "fmt" "log/slog" "os" - "strconv" "unsafe" //nolint:gocritic // used with cgo, requires separate import block + "github.com/dahui/z13gui/internal/uiscale" "github.com/diamondburned/gotk4/pkg/gdk/v4" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) const ( - referenceWidth = 1707.0 // 2560 / 1.5; matches KDE 150% at Z13 native resolution - minScale = 1.0 // lower bound for UI scale - maxScale = 3.0 // upper bound for UI scale - marginFraction = 20 // screen height / N for 5% top/bottom margins - fullOpacity = 0xFFFFFFFF // _NET_WM_WINDOW_OPACITY value for fully visible + marginFraction = 20 // screen height / N for 5% top/bottom margins + fullOpacity = 0xFFFFFFFF // _NET_WM_WINDOW_OPACITY value for fully visible ) // Backend manages the gamescope X11 overlay window. @@ -116,7 +116,7 @@ func (b *Backend) Configure(_ func() bool, onDismiss func()) { return } b.xdisplay = C.display_get_xdisplay(unsafe.Pointer(display.Native())) //nolint:govet // GObject pointer is C-heap-allocated and pinned; uintptr→unsafe.Pointer is safe - b.xid = C.surface_get_xid(unsafe.Pointer(surface.Native())) //nolint:govet // GObject pointer is C-heap-allocated and pinned; uintptr→unsafe.Pointer is safe + b.xid = C.surface_get_xid(unsafe.Pointer(surface.Native())) //nolint:govet // GObject pointer is C-heap-allocated and pinned; uintptr→unsafe.Pointer is safe b.ready = true // Store output dimensions for WrapContent (which runs after realize). @@ -127,18 +127,13 @@ func (b *Backend) Configure(_ func() bool, onDismiss func()) { geo := monitor.Geometry() b.outputWidth = geo.Width() b.outputHeight = geo.Height() - if envScale := os.Getenv("Z13GUI_SCALE"); envScale != "" { - if v, err := strconv.ParseFloat(envScale, 64); err == nil && v > 0 { - b.scale = v - } - } else { - b.scale = float64(geo.Width()) / referenceWidth - } - if b.scale < minScale { - b.scale = minScale - } - if b.scale > maxScale { - b.scale = maxScale + envScale := os.Getenv("Z13GUI_SCALE") + b.scale = uiscale.For(geo.Width(), envScale) + if envScale != "" && !uiscale.OverrideIsUsable(envScale) { + slog.Warn("gamescope: Z13GUI_SCALE is not a positive number, auto-detecting", "value", envScale) + } else if uiscale.OverrideWasClamped(envScale) { + slog.Warn("gamescope: Z13GUI_SCALE clamped to the usable range", + "requested", envScale, "applied", b.scale, "min", uiscale.Min, "max", uiscale.Max) } b.appWin.SetDefaultSize(geo.Width(), geo.Height()) slog.Info("gamescope: sized to monitor", "w", geo.Width(), "h", geo.Height(), "scale", b.scale) @@ -218,6 +213,16 @@ func (b *Backend) WrapContent(drawer gtk.Widgetter) gtk.Widgetter { return wrapper } +// Scale returns the resolution-derived CSS scale factor. Anything the drawer +// paints itself (the fan curve chart) must apply this too — scaledCSS only +// reaches styled widget properties. +func (b *Backend) Scale() float64 { + if b.scale <= 0 { + return 1.0 // realize has not run yet + } + return b.scale +} + // Show makes the overlay visible by setting full opacity and captures input // via STEAM_INPUT_FOCUS atom and an X11 keyboard grab. No pointer grab is // used — XGrabPointer's core event mask interferes with XI2 touch delivery, @@ -297,35 +302,44 @@ func (b *Backend) scaledCSS() string { .tdp-warning { font-size: %.0fpx; margin-top: %.0fpx; margin-bottom: %.0fpx; } .fan-curve-area { min-height: %.0fpx; border-radius: %.0fpx; } .custom-actions button { min-height: %.0fpx; padding: %.0fpx %.0fpx; border-radius: %.0fpx; } -.advanced-check { min-height: %.0fpx; padding: %.0fpx %.0fpx; border-radius: %.0fpx; }`, +.advanced-check { min-height: %.0fpx; padding: %.0fpx %.0fpx; border-radius: %.0fpx; } +.error-bar { padding: %.0fpx %.0fpx; margin: 0 %.0fpx %.0fpx %.0fpx; border-radius: %.0fpx; } +.error-bar .error-text { font-size: %.0fpx; } +.error-bar .error-dismiss { min-height: %.0fpx; min-width: %.0fpx; }`, s, - 14*s, // .drawer font-size - 48*s, 4*s, 10*s, 6*s, // btn-group button - 48*s, 4*s, 10*s, 6*s, // checkbutton - 52*s, // mode-grid btn-group button - 48*s, // tab-btn - 24*s, 24*s, // scale slider - 6*s, // scale value margin - 11*s, 3*s, // drawer-title - 10*s, 0.5*s, // header-telemetry (font-size, letter-spacing) - 13*s, 2*s, 2*s, // section-group - 11*s, 1*s, 6*s, 2*s, // section-label - 10*s, 2*s, 2*s, // scale-value - 10*s, 4*s, // scale-name - 28*s, 28*s, 4*s, // color-swatch - 28*s, 28*s, 4*s, // color-preset - 32*s, 32*s, 4*s, 6*s, // bottom-bar button - 9*s, 1*s, // accent-label - 2*s, // accent-dot-active border - 10*s, 0.5*s, // toggle-label - 20*s, 36*s, 10*s, // bottom-bar switch (height, width, border-radius) - 16*s, 16*s, 8*s, // switch slider (width, height, border-radius) - 32*s, 32*s, 4*s, // view-back-btn - 2*s, 2*s, // gamepad-focus (outline-width, outline-offset) - 2*s, 2*s, // gamepad-editing (outline-width, outline-offset) - 10*s, 4*s, 4*s, // tdp-warning (font-size, margin-top, margin-bottom) - 240*s, 6*s, // fan-curve-area (min-height, border-radius) - 36*s, 4*s, 8*s, 6*s, // custom-actions button (min-height, padding-v, padding-h, border-radius) - 36*s, 4*s, 10*s, 6*s, // advanced-check (min-height, padding-v, padding-h, border-radius) + 14*s, // .drawer font-size + 48*s, 4*s, 10*s, 6*s, // btn-group button + 48*s, 4*s, 10*s, 6*s, // checkbutton + 52*s, // mode-grid btn-group button + 48*s, // tab-btn + 24*s, 24*s, // scale slider + 6*s, // scale value margin + 11*s, 3*s, // drawer-title + 10*s, 0.5*s, // header-telemetry (font-size, letter-spacing) + 13*s, 2*s, 2*s, // section-group + 11*s, 1*s, 6*s, 2*s, // section-label + 10*s, 2*s, 2*s, // scale-value + 10*s, 4*s, // scale-name + 28*s, 28*s, 4*s, // color-swatch + 28*s, 28*s, 4*s, // color-preset + 32*s, 32*s, 4*s, 6*s, // bottom-bar button + 9*s, 1*s, // accent-label + 2*s, // accent-dot-active border + 10*s, 0.5*s, // toggle-label + 20*s, 36*s, 10*s, // bottom-bar switch (height, width, border-radius) + 16*s, 16*s, 8*s, // switch slider (width, height, border-radius) + 32*s, 32*s, 4*s, // view-back-btn + 2*s, 2*s, // gamepad-focus (outline-width, outline-offset) + 2*s, 2*s, // gamepad-editing (outline-width, outline-offset) + 10*s, 4*s, 4*s, // tdp-warning (font-size, margin-top, margin-bottom) + 240*s, 6*s, // fan-curve-area (min-height, border-radius) + 36*s, 4*s, 8*s, 6*s, // custom-actions button (min-height, padding-v, padding-h, border-radius) + 36*s, 4*s, 10*s, 6*s, // advanced-check (min-height, padding-v, padding-h, border-radius) + // Error bar. Omitting it left the drawer's only failure report at 1x while + // everything around it scaled — smallest text on screen, and a dismiss + // button too small to hit, in the mode where that matters most. + 6*s, 8*s, 8*s, 4*s, 8*s, 6*s, // error-bar (padding-v, padding-h, margin r/b/l, border-radius) + 10*s, // error-text font-size + 20*s, 20*s, // error-dismiss (min-height, min-width) ) } diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 3a104c0..fae57d0 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + // Package gui implements the GTK4 overlay drawer for z13gui. // It provides the main Window type that handles daemon state synchronization, // GTK widget construction, and theming. Display-mode-specific concerns @@ -9,13 +12,16 @@ import ( "log/slog" "os" "path/filepath" + "sync/atomic" "time" "github.com/dahui/z13ctl/api" + "github.com/dahui/z13gui/internal/daemon" "github.com/dahui/z13gui/internal/gui/fonts" "github.com/dahui/z13gui/internal/gui/gamepad" "github.com/dahui/z13gui/internal/gui/gamescope" "github.com/dahui/z13gui/internal/gui/layershell" + "github.com/dahui/z13gui/internal/power" "github.com/dahui/z13gui/internal/theme" "github.com/dahui/z13gui/internal/togglegate" "github.com/diamondburned/gotk4/pkg/gdk/v4" @@ -56,11 +62,38 @@ type Window struct { gamescope bool // true when running under gamescope (X11 overlay mode) state *api.State // latest daemon state; nil until first successful fetch tab string // active device tab: "keyboard" or "lightbar" - visible bool // true when the drawer is on-screen or animating in + + // visible is true when the drawer is on-screen or animating in. Atomic + // because it is the one piece of Window state read off the GTK thread: the + // gamepad reader's goroutine gates every event on it. A plain bool there is a + // data race, and internal/gui is not covered by `go test -race`, so nothing + // would ever report it. Written only from show/hide on the GTK thread. + visible atomic.Bool + + // grabGen orders the gamepad grab/release requests show and hide issue from + // their own goroutines. Incremented on the GTK thread, which is the only place + // that knows the intended order. See gamepad.Reader.SetGrabbed. + grabGen uint64 swatchProvider *gtk.CSSProvider // dynamic swatch background colors themeProvider *gtk.CSSProvider // current theme; replaced on applyTheme() + // colors is the active palette, kept alongside the CSS built from it because + // the fan curve chart is painted with Cairo rather than styled by CSS and so + // cannot read the @z13-* tokens. Without this the chart was the one part of + // the drawer the theme did not reach. + colors theme.Colors + + errBar *gtk.Box // error surface; hidden unless an operation failed + errLabel *gtk.Label // message shown in errBar + errDismissBtn *gtk.Button // dismiss button; navigable in every view's focus grid + + // limits is the device's power/thermal envelope, driving every TDP and fan + // curve bound in the custom view. Defaulted to the Z13's values; when z13ctl + // grows an API for serving per-device limits this is the one place that + // changes — fetch once at startup, Sanitized, falling back to the defaults. + limits power.Limits + // Widget references for syncState. tabKB *gtk.CheckButton tabLB *gtk.CheckButton @@ -107,6 +140,7 @@ type Window struct { telemetryTempLabel *gtk.Label telemetryFanLabel *gtk.Label telemetryGen int + telemetryBusy bool // a poll request is in flight; skip ticks until it lands customFocusItems []focusItem syncing bool // true while syncState is updating widgets; suppresses sendApply @@ -157,6 +191,8 @@ type Window struct { func New(app *gtk.Application) *Window { w := &Window{ tab: "keyboard", + limits: power.DefaultLimits(), + colors: theme.DefaultColors, gamescope: os.Getenv("GAMESCOPE_WAYLAND_DISPLAY") != "", modeButtons: make(map[string]*gtk.Button), speedBtns: make(map[string]*gtk.Button), @@ -174,7 +210,7 @@ func New(app *gtk.Application) *Window { w.backend = layershell.New(w.win, w.gtkWin, drawerWidth) } - w.backend.Configure(func() bool { return w.visible }, w.hide) + w.backend.Configure(w.visible.Load, w.hide) if w.gamescope { w.steamBlocker = gamepad.NewSteamInputBlocker() @@ -201,7 +237,7 @@ func New(app *gtk.Application) *Window { if os.Getenv("Z13GUI_NO_GAMEPAD") == "" { w.gamepadReader = gamepad.New( w.handleGamepadAction, - func() bool { return w.visible }, + w.visible.Load, func(f func()) { glib.IdleAdd(f) }, ) go w.gamepadReader.Run() @@ -236,8 +272,8 @@ func New(app *gtk.Application) *Window { // Toggle shows or hides the drawer. Must be called from the GTK main thread. func (w *Window) Toggle() { - slog.Debug("toggle entered", "visible", w.visible) - if w.visible { + slog.Debug("toggle entered", "visible", w.visible.Load()) + if w.visible.Load() { slog.Info("toggle", "action", "hide") w.hide() } else { @@ -245,17 +281,23 @@ func (w *Window) Toggle() { w.show() fetchStart := time.Now() go func() { - ok, state, err := api.SendGetState() - slog.Debug("SendGetState returned", "ok", ok, "err", err, "elapsed", time.Since(fetchStart)) - if ok && err == nil { - glib.IdleAdd(func() { - slog.Debug("syncState dispatched", "totalElapsed", time.Since(fetchStart)) - w.state = state - w.syncState() - }) - } else if err != nil { - slog.Warn("get state failed", "err", err) + ok, state, rawErr := api.SendGetState() + slog.Debug("SendGetState returned", "ok", ok, "err", rawErr, "elapsed", time.Since(fetchStart)) + // A missing daemon arrives as ok=false with a nil error, so testing err + // alone opened the drawer on stale defaults with nothing to say. That is + // the worst moment to stay quiet: every control is about to lie. + if err := daemon.Err(ok, rawErr); err != nil { + w.reportError("Read daemon state", err) + return + } + if state == nil { + return } + glib.IdleAdd(func() { + slog.Debug("syncState dispatched", "totalElapsed", time.Since(fetchStart)) + w.state = state + w.syncState() + }) }() } } @@ -263,12 +305,30 @@ func (w *Window) Toggle() { // show delegates to the display backend. func (w *Window) show() { slog.Debug("show called") - w.visible = true + w.visible.Store(true) + w.grabGen++ if w.gamepadReader != nil { - go w.gamepadReader.GrabAll() + gen := w.grabGen + go w.gamepadReader.SetGrabbed(gen, true) } + // BlockSteam walks /proc twice (every comm, then every status) to find Steam + // and its children, which is far too much synchronous I/O to do before the + // animation starts. It only has to take effect before the user's first input, + // so it runs alongside the slide instead of in front of it. if w.steamBlocker != nil { - w.steamPID = w.steamBlocker.BlockSteam() + blocker := w.steamBlocker + go func() { + pid := blocker.BlockSteam() + glib.IdleAdd(func() { + // A hide may have landed while /proc was being walked. Undo + // immediately rather than recording a PID nothing will release. + if !w.visible.Load() { + blocker.UnblockSteam(pid) + return + } + w.steamPID = pid + }) + }() } w.backend.Show() w.startTelemetryPolling() @@ -277,24 +337,34 @@ func (w *Window) show() { // hide delegates to the display backend. Resets to main view so the drawer // always opens to the home screen. func (w *Window) hide() { - slog.Debug("hide called", "wasVisible", w.visible) - w.visible = false + slog.Debug("hide called", "wasVisible", w.visible.Load()) + w.visible.Store(false) + w.grabGen++ + gen := w.grabGen // Delay unblock + ungrab so the dismiss button release is consumed before - // Steam resumes input processing. - if w.steamBlocker != nil && w.steamPID > 0 { + // Steam resumes input processing. gen makes that delay safe: a show landing + // inside the 200ms window supersedes this release, which would otherwise hand + // the game the D-pad presses navigating the re-opened drawer. + // Gated on the blocker, not on steamPID: BlockSteam runs off-thread, so a hide + // arriving before it lands would otherwise see steamPID == 0, take the + // immediate path, and skip the delay that lets the dismiss button's release be + // consumed first. UnblockSteam(0) is already a no-op, and show's own goroutine + // releases a block that completes after the drawer has closed. + if w.steamBlocker != nil { pid := w.steamPID w.steamPID = 0 go func() { time.Sleep(200 * time.Millisecond) w.steamBlocker.UnblockSteam(pid) if w.gamepadReader != nil { - w.gamepadReader.UngrabAll() + w.gamepadReader.SetGrabbed(gen, false) } }() } else if w.gamepadReader != nil { - go w.gamepadReader.UngrabAll() + go w.gamepadReader.SetGrabbed(gen, false) } w.hideGamepadFocus() + w.clearError() // don't greet the next open with a stale failure w.telemetryGen++ // stop any running telemetry poll if w.viewStack != nil { w.viewStack.SetVisibleChildName("main") @@ -447,6 +517,7 @@ func (w *Window) loadCSS() { } } } + w.colors = colors w.themeProvider.LoadFromString(theme.BuildThemeCSS(colors, defaultThemeCSS)) slog.Info("theme loaded", "source", "custom-toml", "path", tomlPath) loaded = true @@ -456,6 +527,17 @@ func (w *Window) loadCSS() { if err != nil { slog.Warn("failed to read custom theme CSS, using default", "path", cssPath, "err", err) } else { + // theme.css is loaded verbatim, so a token it references without + // defining is simply undefined and GTK drops every rule using it — + // silently, as a styling gap rather than an error. Name the tokens + // instead of leaving the user to guess why part of the drawer is + // unstyled. Not fatal: the rest of the sheet still applies. + if missing := theme.UndefinedColorTokens(string(data)); len(missing) > 0 { + slog.Warn("custom theme CSS references colors it does not define; "+ + "rules using them will be ignored — add @define-color lines or "+ + "start from `z13gui --print-theme`", + "path", cssPath, "undefined", missing) + } w.themeProvider.LoadFromString(string(data)) slog.Info("theme loaded", "source", "custom-css", "path", cssPath) loaded = true @@ -472,6 +554,7 @@ func (w *Window) loadCSS() { colors.Accent = hex } } + w.colors = colors w.themeProvider.LoadFromString(theme.BuildThemeCSS(colors, defaultThemeCSS)) slog.Info("theme loaded", "source", "builtin", "theme", cfg.Theme, "accent", cfg.Accent) } @@ -498,9 +581,11 @@ func (w *Window) applyTheme(id, accentID string) { colors.Accent = hex } } + w.colors = colors w.themeProvider.LoadFromString(theme.BuildThemeCSS(colors, defaultThemeCSS)) gtk.StyleContextAddProviderForDisplay(display, w.themeProvider, gtk.STYLE_PROVIDER_PRIORITY_USER) theme.SaveAppConfig(theme.AppConfig{Theme: id, Accent: accentID}) + w.redrawFanCurve() slog.Info("theme changed", "id", id, "accent", accentID) } @@ -520,9 +605,26 @@ func (w *Window) applyCustomAccent(accentID string) { gtk.StyleContextRemoveProviderForDisplay(display, w.themeProvider) } w.themeProvider = gtk.NewCSSProvider() + w.colors = colors w.themeProvider.LoadFromString(theme.BuildThemeCSS(colors, defaultThemeCSS)) gtk.StyleContextAddProviderForDisplay(display, w.themeProvider, gtk.STYLE_PROVIDER_PRIORITY_USER) - theme.SaveAppConfig(theme.AppConfig{Accent: accentID}) + // Change only the accent. Saving AppConfig{Accent: …} wrote an empty theme + // key, discarding the user's built-in theme choice — invisible while + // theme.toml exists, since it wins on load, and a silent reset to rog-dark the + // moment they remove it. + cfg := theme.LoadAppConfig() + cfg.Accent = accentID + theme.SaveAppConfig(cfg) + w.redrawFanCurve() +} + +// redrawFanCurve repaints the fan curve chart after a theme change. It is drawn +// with Cairo from w.colors rather than styled by CSS, so swapping the CSS +// provider alone leaves it in the previous theme's colours. +func (w *Window) redrawFanCurve() { + if w.fanCurve != nil { + w.fanCurve.area.QueueDraw() + } } // fileExists returns true if a file exists at the given path. diff --git a/internal/gui/layershell/layershell.go b/internal/gui/layershell/layershell.go index 89c2d84..5e7faf7 100644 --- a/internal/gui/layershell/layershell.go +++ b/internal/gui/layershell/layershell.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + // Package layershell implements the Wayland layer-shell display backend. // It handles layer-shell initialization, margin-based slide animation, // and focus-loss auto-hide for compositors like KDE Plasma, Hyprland, and Sway. @@ -173,6 +176,10 @@ func (b *Backend) WrapContent(drawer gtk.Widgetter) gtk.Widgetter { return drawer } +// Scale is always 1.0: GTK applies the compositor's scale factor itself on +// Wayland, so the drawer's CSS pixel values are already correct. +func (b *Backend) Scale() float64 { return 1.0 } + // Show starts the slide-in animation using a smoothstep easing curve. func (b *Backend) Show() { slog.Debug("backend.Show", "startMargin", b.margin, "rightNeighbor", b.hasRightNeighbor()) diff --git a/internal/gui/layout.css b/internal/gui/layout.css index 94c1bbb..e620ac8 100644 --- a/internal/gui/layout.css +++ b/internal/gui/layout.css @@ -1,3 +1,6 @@ +/* Copyright 2026 Jeff Hagadorn + SPDX-License-Identifier: Apache-2.0 */ + /* layout.css — structural rules only. No colors. Not user-overridable. This file is embedded in the binary and always loaded at PRIORITY_APPLICATION. All element-level rules are scoped to .drawer to avoid affecting child windows @@ -35,6 +38,26 @@ margin-bottom: 4px; } +/* Error bar — sits between the view stack and the bottom bar, hidden until an + operation fails. Wraps rather than truncating: daemon messages name the sysfs + path or the exact constraint that was violated, which is the useful part. */ +.error-bar { + padding: 6px 8px; + margin: 0 8px 4px 8px; + border-radius: 6px; +} + +.error-bar .error-text { + font-size: 10px; + font-weight: bold; +} + +.error-bar .error-dismiss { + min-height: 20px; + min-width: 20px; + padding: 0; +} + /* Fan curve drawing area */ .fan-curve-area { min-height: 240px; @@ -145,7 +168,16 @@ border-radius: 6px; } -/* Accent label in theme picker popover */ +/* Back button in the custom, theme and colour views. Sized here as well as in + the gamescope scaled CSS so the two backends agree at scale 1. */ +.view-back-btn { + min-width: 32px; + min-height: 32px; + padding: 4px; + border-radius: 6px; +} + +/* Accent label in the theme picker */ .accent-label { font-size: 9px; font-weight: bold; @@ -170,11 +202,6 @@ min-width: 36px; } -/* Scrolled container inside theme picker popover */ -popover.z13-popover scrolledwindow { - background: transparent; -} - /* Gamepad focus indicator — outline on the currently focused widget */ .gamepad-focus { outline-width: 2px; diff --git a/internal/gui/log.go b/internal/gui/log.go deleted file mode 100644 index 72f0ccb..0000000 --- a/internal/gui/log.go +++ /dev/null @@ -1,55 +0,0 @@ -package gui - -import ( - "context" - "log/slog" -) - -// filterHandler applies separate level thresholds for app logs vs GTK/GLib -// logs. gotk4's glib.init() routes all GLib/GTK messages through -// slog.Default(), adding a "glib_domain" attribute. This handler uses -// that attribute to distinguish GTK noise from application messages. -type filterHandler struct { - inner slog.Handler - appLevel slog.Level // threshold for app messages (default: Info) - gtkLevel slog.Level // threshold for GTK/GLib messages (default: Error) -} - -// NewFilterHandler wraps inner with split-level filtering. -// appLevel controls the threshold for application log messages. -// gtkLevel controls the threshold for GTK/GLib messages (identified by the -// "glib_domain" attribute that gotk4 adds to every GLib log record). -func NewFilterHandler(inner slog.Handler, appLevel, gtkLevel slog.Level) slog.Handler { - return &filterHandler{inner: inner, appLevel: appLevel, gtkLevel: gtkLevel} -} - -func (h *filterHandler) Enabled(_ context.Context, level slog.Level) bool { - // Must pass if either threshold is met — we can't distinguish source until Handle. - return level >= h.appLevel || level >= h.gtkLevel -} - -func (h *filterHandler) Handle(ctx context.Context, r slog.Record) error { - isGTK := false - r.Attrs(func(a slog.Attr) bool { - if a.Key == "glib_domain" { - isGTK = true - return false - } - return true - }) - if isGTK && r.Level < h.gtkLevel { - return nil - } - if !isGTK && r.Level < h.appLevel { - return nil - } - return h.inner.Handle(ctx, r) -} - -func (h *filterHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &filterHandler{inner: h.inner.WithAttrs(attrs), appLevel: h.appLevel, gtkLevel: h.gtkLevel} -} - -func (h *filterHandler) WithGroup(name string) slog.Handler { - return &filterHandler{inner: h.inner.WithGroup(name), appLevel: h.appLevel, gtkLevel: h.gtkLevel} -} diff --git a/internal/gui/sync.go b/internal/gui/sync.go index 53192a3..972e323 100644 --- a/internal/gui/sync.go +++ b/internal/gui/sync.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package gui // sync.go — daemon state synchronization and API communication. @@ -5,36 +8,16 @@ package gui import ( "fmt" "log/slog" - "strings" "time" "github.com/dahui/z13ctl/api" + "github.com/dahui/z13gui/internal/colorconv" + "github.com/dahui/z13gui/internal/daemon" + "github.com/dahui/z13gui/internal/lighting" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) -// Defaults used when widget state is unavailable (e.g. before first sync). -const ( - defaultColor1 = "FF0000" - defaultColor2 = "000000" - defaultMode = "static" - defaultSpeed = "normal" - defaultBrightness = 3 -) - -// modeVis defines which subsections are visible for a given lighting mode. -type modeVis struct{ color1, color2, speed, brightness bool } - -// modeVisMap maps lighting mode names to their subsection visibility. -var modeVisMap = map[string]modeVis{ - "static": {true, false, false, true}, - "breathe": {true, true, true, true}, - "cycle": {false, false, true, true}, - "rainbow": {false, false, true, true}, - "strobe": {true, false, true, true}, - "off": {false, false, false, false}, -} - // activeButton returns the key of the button with the .active CSS class, // or the fallback value if none is found. func activeButton(btns map[string]*gtk.Button, fallback string) string { @@ -49,22 +32,18 @@ func activeButton(btns map[string]*gtk.Button, fallback string) string { // syncModeVis shows/hides color and speed sections based on the active mode. // Safe to call at any time (including during sync). func (w *Window) syncModeVis() { - mode := activeButton(w.modeButtons, "static") - v, ok := modeVisMap[mode] - if !ok { - v = modeVis{true, true, true, true} - } + c := lighting.ControlsFor(activeButton(w.modeButtons, lighting.DefaultMode)) if w.color1Box != nil { - w.color1Box.SetVisible(v.color1) + w.color1Box.SetVisible(c.Color1) } if w.color2Box != nil { - w.color2Box.SetVisible(v.color2) + w.color2Box.SetVisible(c.Color2) } if w.speedBox != nil { - w.speedBox.SetVisible(v.speed) + w.speedBox.SetVisible(c.Speed) } if w.brightBox != nil { - w.brightBox.SetVisible(v.brightness) + w.brightBox.SetVisible(c.Brightness) } } @@ -94,29 +73,30 @@ func (w *Window) syncLightingSection() { w.syncing = true defer func() { w.syncing = prev }() - var ls api.LightingState - if w.state != nil { - if dev, ok := w.state.Devices[w.tab]; ok { - ls = dev - } else { - ls = w.state.Lighting + ls := lighting.StateForZone(w.state, w.tab) + setActiveButton(w.modeButtons, lighting.ResolveMode(ls)) + // Normalize on ingest. Daemon state is not guaranteed well-formed — z13ctl has + // had corrupt-state-file bugs — and an unparseable colour used to silently + // become black in the picker and then be written back to the hardware on the + // next apply. Keep the previous value instead. + if w.color1 != nil { + if hex, ok := colorconv.Normalize(ls.Color); ok { + w.color1.hex = hex + } else if ls.Color != "" { + slog.Warn("daemon sent an unparseable color, keeping previous", "zone", w.tab, "field", "color", "value", ls.Color) } } - mode := ls.Mode - if !ls.Enabled { - mode = "off" - } - setActiveButton(w.modeButtons, mode) - if w.color1 != nil && ls.Color != "" { - w.color1.hex = strings.ToUpper(ls.Color) - } - if w.color2 != nil && ls.Color2 != "" { - w.color2.hex = strings.ToUpper(ls.Color2) + if w.color2 != nil { + if hex, ok := colorconv.Normalize(ls.Color2); ok { + w.color2.hex = hex + } else if ls.Color2 != "" { + slog.Warn("daemon sent an unparseable color, keeping previous", "zone", w.tab, "field", "color2", "value", ls.Color2) + } } w.updateSwatches() - setActiveButton(w.speedBtns, ls.Speed) + setActiveButton(w.speedBtns, lighting.ResolveSpeed(ls)) if w.brightScale != nil { - w.brightScale.SetValue(float64(ls.Brightness)) + w.brightScale.SetValue(float64(lighting.ResolveBrightness(ls))) } w.syncModeVis() } @@ -161,73 +141,107 @@ func (w *Window) sendApply() { if w.syncing { return } - color1 := defaultColor1 + color1 := lighting.DefaultColor1 if w.color1 != nil { color1 = w.color1.hex } - color2 := defaultColor2 + color2 := lighting.DefaultColor2 if w.color2 != nil { color2 = w.color2.hex } - mode := activeButton(w.modeButtons, defaultMode) - speed := activeButton(w.speedBtns, defaultSpeed) + mode := activeButton(w.modeButtons, lighting.DefaultMode) + speed := activeButton(w.speedBtns, lighting.DefaultSpeed) - brightness := defaultBrightness + brightness := lighting.DefaultBrightness if w.brightScale != nil { brightness = int(w.brightScale.Value()) } + // Widget reads happen above, on the GTK thread; only the socket round-trip + // runs in the goroutine. api commands carry a 10s deadline, so calling them + // inline would freeze the drawer for that long against a wedged daemon. + device := w.tab + // "off" uses the daemon's dedicated off command so that Enabled=false // is persisted and survives a reboot. if mode == "off" { - slog.Debug("sendApply: calling daemon off", "device", w.tab) - start := time.Now() - if _, err := api.SendOff(w.tab); err != nil { - slog.Warn("off failed", "err", err, "elapsed", time.Since(start)) - } else { + go func() { + slog.Debug("sendApply: calling daemon off", "device", device) + start := time.Now() + if err := daemon.Err(api.SendOff(device)); err != nil { + w.reportError("Turn off "+device+" lighting", err) + return + } + w.clearErrorAsync() slog.Debug("sendApply: off done", "elapsed", time.Since(start)) - } + }() return } - slog.Debug("sendApply: calling daemon", "device", w.tab, "mode", mode, "brightness", brightness) - start := time.Now() - if _, err := api.SendApply(w.tab, color1, color2, mode, speed, brightness); err != nil { - slog.Warn("apply failed", "err", err, "elapsed", time.Since(start)) - } else { + go func() { + slog.Debug("sendApply: calling daemon", "device", device, "mode", mode, "brightness", brightness) + start := time.Now() + if err := daemon.Err(api.SendApply(device, color1, color2, mode, speed, brightness)); err != nil { + w.reportError("Apply "+device+" lighting", err) + return + } + w.clearErrorAsync() slog.Debug("sendApply: done", "elapsed", time.Since(start)) - } + }() } // sendProfileSet sends a profile change to the daemon. +// The state refresh runs on this same goroutine, after the set returns. It must +// not be a separate goroutine: switching to a stock profile makes the daemon +// rewrite the PPT values and release the fans to firmware auto, and a concurrent +// get-state can read the old values and repaint the custom view with them. func (w *Window) sendProfileSet(prof string) { - slog.Debug("sendProfileSet: calling daemon", "profile", prof) - start := time.Now() - if _, err := api.SendProfileSet(prof); err != nil { - slog.Warn("profile set failed", "profile", prof, "err", err, "elapsed", time.Since(start)) - } else { + go func() { + slog.Debug("sendProfileSet: calling daemon", "profile", prof) + start := time.Now() + if err := daemon.Err(api.SendProfileSet(prof)); err != nil { + w.reportError("Set "+prof+" profile", err) + return + } + w.clearErrorAsync() slog.Debug("sendProfileSet: done", "elapsed", time.Since(start)) - } + w.refreshState() + }() } // initBatteryDebounce sets up debounced battery limit changes on the given scale. func (w *Window) initBatteryDebounce(sc *gtk.Scale) { var debounce *time.Timer sc.ConnectValueChanged(func() { + // The syncing guard every other input has. syncBattery sets this scale from + // daemon state, which fires this handler, so without it every drawer open + // wrote the limit straight back to the hardware 200ms later. Harmless while + // the write succeeds — it is the value the daemon just reported — but on a + // device that rejects it that is now a visible error bar on every open, + // since daemon failures are no longer swallowed. + // + // Checked here rather than in the timer: by the time it fires the sync has + // long finished and the flag is false again. + if w.syncing { + return + } if debounce != nil { debounce.Stop() } debounce = time.AfterFunc(200*time.Millisecond, func() { glib.IdleAdd(func() bool { - val := int(sc.Value()) - slog.Debug("sendBatteryLimitSet: calling daemon", "limit", val) - start := time.Now() - if _, err := api.SendBatteryLimitSet(val); err != nil { - slog.Warn("battery limit set failed", "err", err, "elapsed", time.Since(start)) - } else { + val := int(sc.Value()) // scale read must stay on the GTK thread + go func() { + slog.Debug("sendBatteryLimitSet: calling daemon", "limit", val) + start := time.Now() + if err := daemon.Err(api.SendBatteryLimitSet(val)); err != nil { + w.reportError("Set battery limit", err) + return + } + w.clearErrorAsync() slog.Debug("sendBatteryLimitSet: done", "elapsed", time.Since(start)) - } + }() return false }) }) @@ -252,22 +266,28 @@ func (w *Window) syncBootSound() { // sendOverdriveSet sends a panel overdrive change to the daemon. func (w *Window) sendOverdriveSet(value int) { - slog.Debug("sendOverdriveSet: calling daemon", "value", value) - start := time.Now() - if _, err := api.SendPanelOverdriveSet(value); err != nil { - slog.Warn("panel overdrive set failed", "value", value, "err", err, "elapsed", time.Since(start)) - } else { + go func() { + slog.Debug("sendOverdriveSet: calling daemon", "value", value) + start := time.Now() + if err := daemon.Err(api.SendPanelOverdriveSet(value)); err != nil { + w.reportError("Set panel overdrive", err) + return + } + w.clearErrorAsync() slog.Debug("sendOverdriveSet: done", "elapsed", time.Since(start)) - } + }() } // sendBootSoundSet sends a boot sound change to the daemon. func (w *Window) sendBootSoundSet(value int) { - slog.Debug("sendBootSoundSet: calling daemon", "value", value) - start := time.Now() - if _, err := api.SendBootSoundSet(value); err != nil { - slog.Warn("boot sound set failed", "value", value, "err", err, "elapsed", time.Since(start)) - } else { + go func() { + slog.Debug("sendBootSoundSet: calling daemon", "value", value) + start := time.Now() + if err := daemon.Err(api.SendBootSoundSet(value)); err != nil { + w.reportError("Set boot sound", err) + return + } + w.clearErrorAsync() slog.Debug("sendBootSoundSet: done", "elapsed", time.Since(start)) - } + }() } diff --git a/internal/gui/tdp.go b/internal/gui/tdp.go index aa529cb..1f891d3 100644 --- a/internal/gui/tdp.go +++ b/internal/gui/tdp.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package gui // tdp.go — Custom profile view: TDP sliders, fan curve editor, telemetry. @@ -6,145 +9,142 @@ import ( "fmt" "log/slog" "math" - "strings" "github.com/dahui/z13ctl/api" + "github.com/dahui/z13gui/internal/colorconv" + "github.com/dahui/z13gui/internal/daemon" + "github.com/dahui/z13gui/internal/power" + "github.com/dahui/z13gui/internal/theme" "github.com/diamondburned/gotk4/pkg/cairo" "github.com/diamondburned/gotk4/pkg/glib/v2" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) -// TDP limits (matching daemon constants). -const ( - tdpMin = 5 - tdpMaxBasic = 70 // basic slider max - tdpMaxSafe = 75 // warning threshold - tdpMaxAdvanced = 93 // advanced slider max (force=true above 75) -) - // fanCurveEditor renders and handles interaction for the 8-point fan curve. +// The curve model and its constraint rules live in internal/power; this type +// owns only the drawing and pointer handling. type fanCurveEditor struct { area *gtk.DrawingArea - points [8]api.FanCurvePoint // temp: 0–120°C, pwm: 0–255 - dragging int // point index being dragged, -1 if none - hovered int // point index under cursor, -1 if none - w *Window // parent for theme colors + telemetry + points power.Curve // temp/PWM bounds come from Window.limits + dragging int // point index being dragged, -1 if none + hovered int // point index under cursor, -1 if none + w *Window // parent for theme colors + telemetry // Chart area within the DrawingArea (set during draw). chartX, chartY, chartW, chartH float64 } -// defaultFanCurve returns a reasonable default fan curve. -func defaultFanCurve() [8]api.FanCurvePoint { - return [8]api.FanCurvePoint{ - {Temp: 35, PWM: 0}, - {Temp: 45, PWM: 25}, - {Temp: 50, PWM: 50}, - {Temp: 60, PWM: 80}, - {Temp: 70, PWM: 120}, - {Temp: 80, PWM: 170}, - {Temp: 90, PWM: 220}, - {Temp: 100, PWM: 255}, +// curveString returns the curve in "temp:pwm,temp:pwm,..." format for the API. +func (fc *fanCurveEditor) curveString() string { return fc.points.String() } + +// limits returns the device envelope driving the editor's axes and clamping, +// falling back to the defaults when the editor has no parent window. +func (fc *fanCurveEditor) limits() power.Limits { + if fc.w == nil { + return power.DefaultLimits() } + return fc.w.limits } -// curveString returns the curve in "temp:pwm,temp:pwm,..." format for the API. -func (fc *fanCurveEditor) curveString() string { - var parts []string - for _, p := range fc.points { - parts = append(parts, fmt.Sprintf("%d:%d", p.Temp, p.PWM)) - } - return strings.Join(parts, ",") +// tempRange is the editor's x axis, in Celsius. +func (fc *fanCurveEditor) tempRange() (lo, hi int) { + l := fc.limits() + return l.TempMin, l.TempMax } -// enforceConstraints ensures temps are strictly increasing and PWM non-decreasing. -func (fc *fanCurveEditor) enforceConstraints(idx int) { - // Clamp the dragged point first. - if fc.points[idx].Temp < 35 { - fc.points[idx].Temp = 35 - } - if fc.points[idx].Temp > 105 { - fc.points[idx].Temp = 105 - } - if fc.points[idx].PWM < 0 { - fc.points[idx].PWM = 0 - } - if fc.points[idx].PWM > 255 { - fc.points[idx].PWM = 255 +// fanFloorPWM returns the minimum fan PWM the daemon will currently accept. +// +// Derived from the daemon's applied state rather than the slider position: the +// daemon validates against hardware, and a slider the user has moved but not +// saved has not been applied. Must be called from the GTK main thread. +func (w *Window) fanFloorPWM() int { + if w.state == nil || w.state.TDP == nil { + return power.PWMMin } + return w.limits.FanFloorPWM(w.state.TDP.PL1SPL) +} - // Cascade temps forward (must be strictly increasing). - for i := idx + 1; i < 8; i++ { - if fc.points[i].Temp <= fc.points[i-1].Temp { - fc.points[i].Temp = fc.points[i-1].Temp + 1 - } - } - // Cascade temps backward. - for i := idx - 1; i >= 0; i-- { - if fc.points[i].Temp >= fc.points[i+1].Temp { - fc.points[i].Temp = fc.points[i+1].Temp - 1 - } - } - // Cascade PWM forward (must be non-decreasing). - for i := idx + 1; i < 8; i++ { - if fc.points[i].PWM < fc.points[i-1].PWM { - fc.points[i].PWM = fc.points[i-1].PWM - } - } - // Cascade PWM backward. - for i := idx - 1; i >= 0; i-- { - if fc.points[i].PWM > fc.points[i+1].PWM { - fc.points[i].PWM = fc.points[i+1].PWM - } - } - // Final clamp pass. - for i := range fc.points { - if fc.points[i].Temp < 35 { - fc.points[i].Temp = 35 - } - if fc.points[i].Temp > 105 { - fc.points[i].Temp = 105 - } - if fc.points[i].PWM < 0 { - fc.points[i].PWM = 0 - } - if fc.points[i].PWM > 255 { - fc.points[i].PWM = 255 - } +// minPWM is the editor's view of fanFloorPWM, safe when the editor has no parent. +func (fc *fanCurveEditor) minPWM() int { + if fc.w == nil { + return power.PWMMin } + return fc.w.fanFloorPWM() +} + +// enforceConstraints repairs the curve after point idx moved. The rules live in +// internal/power, where they are unit tested. +func (fc *fanCurveEditor) enforceConstraints(idx int) { + fc.limits().EnforceCurve(&fc.points, idx, fc.minPWM()) } // Coordinate mapping. func (fc *fanCurveEditor) tempToX(temp int) float64 { - return fc.chartX + (float64(temp-35)/70.0)*fc.chartW // 35–105°C range + lo, hi := fc.tempRange() + return fc.chartX + (float64(temp-lo)/float64(hi-lo))*fc.chartW } func (fc *fanCurveEditor) pwmToY(pwm int) float64 { - return fc.chartY + fc.chartH - (float64(pwm)/255.0)*fc.chartH // inverted + return fc.chartY + fc.chartH - (float64(pwm)/float64(power.PWMMax))*fc.chartH // inverted } func (fc *fanCurveEditor) xToTemp(x float64) int { - t := 35 + int(math.Round((x-fc.chartX)/fc.chartW*70.0)) - if t < 35 { - t = 35 + lo, hi := fc.tempRange() + t := lo + int(math.Round((x-fc.chartX)/fc.chartW*float64(hi-lo))) + if t < lo { + t = lo } - if t > 105 { - t = 105 + if t > hi { + t = hi } return t } func (fc *fanCurveEditor) yToPWM(y float64) int { - p := int(math.Round((fc.chartY + fc.chartH - y) / fc.chartH * 255.0)) - if p < 0 { - p = 0 + p := int(math.Round((fc.chartY + fc.chartH - y) / fc.chartH * float64(power.PWMMax))) + if p < power.PWMMin { + p = power.PWMMin } - if p > 255 { - p = 255 + if p > power.PWMMax { + p = power.PWMMax } return p } +// scale returns the factor the drawer's sizes are multiplied by: 1.0 on +// layer-shell, resolution-derived under gamescope. Everything drawn here is +// Cairo rather than CSS, so it has to apply the factor itself — the chart grew +// with .fan-curve-area while the points, fonts and margins stayed at 1x, which +// left the grab targets progressively harder to hit as resolution went up. +func (fc *fanCurveEditor) scale() float64 { + if fc.w == nil || fc.w.backend == nil { + return 1.0 + } + if s := fc.w.backend.Scale(); s > 0 { + return s + } + return 1.0 +} + +// rgb returns the theme colour named by hex as Cairo components, falling back to +// the default palette's value when the theme supplied something unparseable — +// drawing the zero value would be black, which vanishes on a dark background. +func (fc *fanCurveEditor) rgb(hex, fallback string) (r, g, b float64) { + if cr, cg, cb, ok := colorconv.RGB(hex); ok { + return cr, cg, cb + } + if cr, cg, cb, ok := colorconv.RGB(fallback); ok { + return cr, cg, cb + } + return 1, 1, 1 +} + +// pointRadius is the drawn radius of a curve point, and hitRadius the distance +// within which a press grabs one. The hit radius is deliberately the larger: +// these are dragged with a thumb on a touchscreen. +func (fc *fanCurveEditor) pointRadius() float64 { return 6.0 * fc.scale() } +func (fc *fanCurveEditor) hitRadius() float64 { return 20.0 * fc.scale() } + // hitTest returns the index of the point nearest to (x,y) within tolerance, or -1. func (fc *fanCurveEditor) hitTest(x, y float64) int { - const tolerance = 20.0 + tolerance := fc.hitRadius() best := -1 bestDist := tolerance * tolerance for i, p := range fc.points { @@ -165,32 +165,47 @@ func (fc *fanCurveEditor) hitTest(x, y float64) int { func (fc *fanCurveEditor) draw(cr *cairo.Context, width, height int) { w := float64(width) h := float64(height) - - // Chart margins. - const leftMargin = 36.0 - const bottomMargin = 20.0 - const topMargin = 8.0 - const rightMargin = 8.0 + s := fc.scale() + th := theme.DefaultColors + if fc.w != nil { + th = fc.w.colors + } + fontSize := 9 * s + + // Chart margins. Scaled with everything else: the y-axis labels have to fit in + // leftMargin, and they grow with fontSize. + leftMargin := 36.0 * s + bottomMargin := 20.0 * s + topMargin := 8.0 * s + rightMargin := 8.0 * s fc.chartX = leftMargin fc.chartY = topMargin fc.chartW = w - leftMargin - rightMargin fc.chartH = h - topMargin - bottomMargin + // Nothing sensible to draw if the widget has not been allocated a usable size + // yet; the coordinate helpers divide by chartW/chartH. + if fc.chartW <= 0 || fc.chartH <= 0 { + return + } + // Background. cr.SetSourceRGBA(0, 0, 0, 0) // transparent — CSS handles bg cr.Paint() // Grid lines. - cr.SetSourceRGBA(0.4, 0.4, 0.4, 0.3) - cr.SetLineWidth(0.5) + gr, gg, gb := fc.rgb(th.Border, theme.DefaultColors.Border) + cr.SetSourceRGBA(gr, gg, gb, 0.6) + cr.SetLineWidth(0.5 * s) // Horizontal: 0%, 25%, 50%, 75%, 100%. for _, pct := range []float64{0, 25, 50, 75, 100} { - y := fc.pwmToY(int(pct / 100.0 * 255)) + y := fc.pwmToY(int(pct / 100.0 * power.PWMMax)) cr.MoveTo(fc.chartX, y) cr.LineTo(fc.chartX+fc.chartW, y) } - // Vertical: every 10°C from 35 to 105. - for temp := 35; temp <= 105; temp += 10 { + // Vertical: every 10°C across the device's range. + tLo, tHi := fc.tempRange() + for temp := tLo; temp <= tHi; temp += 10 { x := fc.tempToX(temp) cr.MoveTo(x, fc.chartY) cr.LineTo(x, fc.chartY+fc.chartH) @@ -198,29 +213,52 @@ func (fc *fanCurveEditor) draw(cr *cairo.Context, width, height int) { cr.Stroke() // Axis labels. - cr.SetSourceRGBA(0.6, 0.6, 0.6, 1) - cr.SetFontSize(9) + dr, dg, db := fc.rgb(th.TextDim, theme.DefaultColors.TextDim) + cr.SetSourceRGBA(dr, dg, db, 1) + cr.SetFontSize(fontSize) // Y-axis labels. for _, pct := range []int{0, 25, 50, 75, 100} { - y := fc.pwmToY(int(float64(pct) / 100.0 * 255)) - cr.MoveTo(2, y+3) + y := fc.pwmToY(int(float64(pct) / 100.0 * power.PWMMax)) + cr.MoveTo(2*s, y+3*s) cr.ShowText(fmt.Sprintf("%d%%", pct)) } // X-axis labels. - for temp := 40; temp <= 100; temp += 20 { + for temp := tLo + 5; temp <= tHi-5; temp += 20 { x := fc.tempToX(temp) - cr.MoveTo(x-8, fc.chartY+fc.chartH+14) + cr.MoveTo(x-8*s, fc.chartY+fc.chartH+14*s) cr.ShowText(fmt.Sprintf("%d°", temp)) } + // High-TDP fan floor. While sustained TDP is above the safe max the daemon + // rejects any point below this line, and enforceConstraints holds drags at or + // above it — drawing it explains why the points will not go lower. + if floor := fc.minPWM(); floor > 0 { + fy := fc.pwmToY(floor) + // Same @z13-error token as the error bar and .tdp-warning: this line marks + // a limit the daemon enforces, so it should read as the theme's warning + // colour rather than a hardcoded red that clashes with light palettes. + er, eg, eb := fc.rgb(th.Error, theme.DefaultColors.Error) + cr.SetSourceRGBA(er, eg, eb, 0.9) + cr.SetLineWidth(1.5 * s) + cr.SetDash([]float64{6 * s, 3 * s}, 0) + cr.MoveTo(fc.chartX, fy) + cr.LineTo(fc.chartX+fc.chartW, fy) + cr.Stroke() + cr.SetDash(nil, 0) + cr.SetFontSize(fontSize) + cr.MoveTo(fc.chartX+4*s, fy-4*s) + cr.ShowText(fmt.Sprintf("%d%% min (TDP > %dW)", floor*100/power.PWMMax, fc.limits().TDPMaxSafe)) + } + // Current APU temperature indicator line. if fc.w != nil && fc.w.state != nil && fc.w.state.Temperature > 0 { apuTemp := fc.w.state.Temperature - if apuTemp >= 35 && apuTemp <= 105 { + if apuTemp >= tLo && apuTemp <= tHi { tx := fc.tempToX(apuTemp) - cr.SetSourceRGBA(1, 1, 1, 0.4) - cr.SetLineWidth(1) - cr.SetDash([]float64{4, 3}, 0) + tr, tg, tb := fc.rgb(th.Text, theme.DefaultColors.Text) + cr.SetSourceRGBA(tr, tg, tb, 0.4) + cr.SetLineWidth(1 * s) + cr.SetDash([]float64{4 * s, 3 * s}, 0) cr.MoveTo(tx, fc.chartY) cr.LineTo(tx, fc.chartY+fc.chartH) cr.Stroke() @@ -228,19 +266,21 @@ func (fc *fanCurveEditor) draw(cr *cairo.Context, width, height int) { } } + ar, ag, ab := fc.rgb(th.Accent, theme.DefaultColors.Accent) + // Filled area under curve. - cr.SetSourceRGBA(0.8, 0.1, 0.1, 0.15) // accent-ish, semi-transparent + cr.SetSourceRGBA(ar, ag, ab, 0.15) cr.MoveTo(fc.tempToX(fc.points[0].Temp), fc.pwmToY(0)) for _, p := range fc.points { cr.LineTo(fc.tempToX(p.Temp), fc.pwmToY(p.PWM)) } - cr.LineTo(fc.tempToX(fc.points[7].Temp), fc.pwmToY(0)) + cr.LineTo(fc.tempToX(fc.points[len(fc.points)-1].Temp), fc.pwmToY(0)) cr.ClosePath() cr.Fill() // Line connecting points. - cr.SetSourceRGBA(0.8, 0.1, 0.1, 1) // accent color - cr.SetLineWidth(2) + cr.SetSourceRGBA(ar, ag, ab, 1) + cr.SetLineWidth(2 * s) for i, p := range fc.points { x := fc.tempToX(p.Temp) y := fc.pwmToY(p.PWM) @@ -253,18 +293,20 @@ func (fc *fanCurveEditor) draw(cr *cairo.Context, width, height int) { cr.Stroke() // Point circles. + hr, hg, hb := fc.rgb(th.Text, theme.DefaultColors.Text) for i, p := range fc.points { x := fc.tempToX(p.Temp) y := fc.pwmToY(p.PWM) - radius := 6.0 + radius := fc.pointRadius() if i == fc.dragging || i == fc.hovered { - radius = 8.0 + radius *= 8.0 / 6.0 // grown while active, in proportion // Outer ring. - cr.SetSourceRGBA(1, 1, 1, 0.6) - cr.Arc(x, y, radius+2, 0, 2*math.Pi) + cr.SetSourceRGBA(hr, hg, hb, 0.6) + cr.SetLineWidth(1 * s) + cr.Arc(x, y, radius+2*s, 0, 2*math.Pi) cr.Stroke() } - cr.SetSourceRGBA(0.8, 0.1, 0.1, 1) + cr.SetSourceRGBA(ar, ag, ab, 1) cr.Arc(x, y, radius, 0, 2*math.Pi) cr.Fill() } @@ -276,12 +318,12 @@ func (w *Window) newFanCurveEditor() *fanCurveEditor { dragging: -1, hovered: -1, w: w, - points: defaultFanCurve(), + points: w.limits.DefaultCurve(), } fc.area = gtk.NewDrawingArea() fc.area.AddCSSClass("fan-curve-area") - fc.area.SetSizeRequest(-1, 240) + fc.area.SetSizeRequest(-1, 240) // .fan-curve-area scales this under gamescope fc.area.SetDrawFunc(func(_ *gtk.DrawingArea, cr *cairo.Context, width, height int) { fc.draw(cr, width, height) }) @@ -399,7 +441,7 @@ func (w *Window) buildCustomView() *gtk.Box { // Basic TDP box (visible by default). tdpBasicBox := gtk.NewBox(gtk.OrientationVertical, 4) - w.tdpBasicScale = gtk.NewScaleWithRange(gtk.OrientationHorizontal, tdpMin, tdpMaxBasic, 1) + w.tdpBasicScale = gtk.NewScaleWithRange(gtk.OrientationHorizontal, float64(w.limits.TDPMin), float64(w.limits.BasicSliderMax()), 1) w.tdpBasicScale.SetDigits(0) w.tdpBasicScale.SetDrawValue(false) w.tdpBasicScale.SetValue(float64(50)) @@ -417,7 +459,9 @@ func (w *Window) buildCustomView() *gtk.Box { w.tdpAdvancedBox = gtk.NewBox(gtk.OrientationVertical, 4) w.tdpAdvancedBox.SetVisible(false) - w.tdpWarningLabel = gtk.NewLabel("WARNING: Values above 75W may cause thermal throttling, instability, or hardware damage. Use at your own risk — we are not responsible for any damages.") + w.tdpWarningLabel = gtk.NewLabel(fmt.Sprintf( + "WARNING: Values above %dW may cause thermal throttling, instability, or hardware damage. Use at your own risk — we are not responsible for any damages.", + w.limits.TDPMaxSafe)) w.tdpWarningLabel.SetWrap(true) w.tdpWarningLabel.SetHAlign(gtk.AlignStart) w.tdpWarningLabel.AddCSSClass("tdp-warning") @@ -540,7 +584,7 @@ func (w *Window) buildTdpScale(label, desc string) (*gtk.Scale, *gtk.Label) { descLabel.SetWrap(true) descLabel.AddCSSClass("scale-value") w.tdpAdvancedBox.Append(descLabel) - sc := gtk.NewScaleWithRange(gtk.OrientationHorizontal, tdpMin, tdpMaxAdvanced, 1) + sc := gtk.NewScaleWithRange(gtk.OrientationHorizontal, float64(w.limits.TDPMin), float64(w.limits.TDPMaxForced), 1) sc.SetDigits(0) sc.SetDrawValue(false) sc.SetValue(50) @@ -598,8 +642,8 @@ func (w *Window) syncCustomView() { tdp := w.state.TDP if w.tdpBasicScale != nil { v := float64(tdp.PL1SPL) - if v > tdpMaxBasic { - v = tdpMaxBasic + if m := float64(w.limits.BasicSliderMax()); v > m { + v = m } w.tdpBasicScale.SetValue(v) w.tdpBasicLabel.SetLabel(fmt.Sprintf("%d W", int(v))) @@ -616,14 +660,40 @@ func (w *Window) syncCustomView() { w.tdpPL3Scale.SetValue(float64(tdp.FPPT)) w.tdpPL3Label.SetLabel(fmt.Sprintf("%d W", tdp.FPPT)) } + + // Switch to the advanced view when the applied TDP cannot be expressed in + // basic mode. Otherwise the basic slider silently clamps and its label + // reports the clamped number, so the drawer claims 70W while the hardware + // runs at 80W. Only ever forced on, never off: once the user unchecks it + // that is a deliberate choice to edit in basic terms. + if w.tdpAdvancedCheck != nil && !w.tdpAdvancedCheck.Active() && + w.limits.NeedsAdvanced(w.state.Profile, *tdp) { + w.tdpAdvancedCheck.SetActive(true) + } } - // Fan curve. - if w.state.FanCurve != nil && len(w.state.FanCurve.Points) == 8 && w.fanCurve != nil { - copy(w.fanCurve.points[:], w.state.FanCurve.Points) + // Fan curve. Only adopt the daemon's points when the fans are actually + // following them: on a stock profile the fans are released to firmware auto + // but the curve registers still read back the last custom curve, so copying + // them unconditionally left the editor showing a curve that was not in force. + // Redraw either way — the PWM floor line depends on PL1, which may have just + // changed. + if w.fanCurve != nil { + if power.FanCurveIsCustom(w.state.FanCurve) { + copy(w.fanCurve.points[:], w.state.FanCurve.Points) + } else { + w.fanCurve.points = w.limits.DefaultCurve() + } + } + if w.fanCurve != nil { + // A curve saved while the floor was off can sit below it once a high TDP + // is applied. Lift it so what is drawn is what the daemon would accept. + w.fanCurve.enforceConstraints(0) w.fanCurve.area.QueueDraw() } + w.syncFanResetSensitivity() + // Undervolt. if w.uvBox != nil { w.uvBox.SetVisible(w.state.UndervoltAvailable) @@ -646,159 +716,213 @@ func (w *Window) syncCustomView() { } } -// sendTdp sends the current TDP slider values to the daemon. -func (w *Window) sendTdp() error { +// syncFanResetSensitivity enables or disables Reset Fans according to the applied +// sustained limit. +// +// The daemon refuses a fan reset while the high-TDP floor is in force — firmware +// auto has no floor, so releasing the fans there would remove the protection the +// power limit requires. Reset TDP is the way out, which the tooltip says. +// +// Separate from syncCustomView because the telemetry poll also needs it: it +// refreshes w.state every second, so a TDP change made elsewhere (the z13ctl CLI, +// or another client) moves the floor line while the button kept its old +// sensitivity until the next full sync. +func (w *Window) syncFanResetSensitivity() { + if w.resetFanBtn == nil { + return + } + floored := w.fanFloorPWM() > 0 + w.resetFanBtn.SetSensitive(!floored) + if floored { + w.resetFanBtn.SetTooltipText(fmt.Sprintf( + "Unavailable while sustained TDP is above %dW — fans must stay at %d%% minimum. Use Reset TDP first.", + w.limits.TDPMaxSafe, w.limits.HighTDPMinPWM*100/power.PWMMax)) + return + } + w.resetFanBtn.SetTooltipText("Reset fan curves to firmware auto") +} + +// tdpRequest is a snapshot of the TDP widgets, taken on the GTK thread so the +// socket call can run in a goroutine without touching widgets from it. +type tdpRequest struct { + watts, pl1, pl2, pl3 string + force bool +} + +// readTdpRequest snapshots the TDP sliders. **Must be called from the GTK main +// thread** — GTK is not thread-safe, and reading a scale from a goroutine is +// undefined behaviour, not merely a stale value. +func (w *Window) readTdpRequest() tdpRequest { if w.tdpAdvancedCheck != nil && w.tdpAdvancedCheck.Active() { - pl1 := fmt.Sprintf("%d", int(w.tdpPL1Scale.Value())) - pl2 := fmt.Sprintf("%d", int(w.tdpPL2Scale.Value())) - pl3 := fmt.Sprintf("%d", int(w.tdpPL3Scale.Value())) - maxPL := int(math.Max(w.tdpPL1Scale.Value(), math.Max(w.tdpPL2Scale.Value(), w.tdpPL3Scale.Value()))) - force := maxPL > tdpMaxSafe - _, err := api.SendTdpSet(pl1, pl1, pl2, pl3, force) - return err + pl1v, pl2v, pl3v := w.tdpPL1Scale.Value(), w.tdpPL2Scale.Value(), w.tdpPL3Scale.Value() + pl1 := fmt.Sprintf("%d", int(pl1v)) + maxPL := int(math.Max(pl1v, math.Max(pl2v, pl3v))) + return tdpRequest{ + // watts doubles as the base value; the daemon parses it before it looks + // at the PL fields and rejects the request outright if it is empty. + watts: pl1, + pl1: pl1, + pl2: fmt.Sprintf("%d", int(pl2v)), + pl3: fmt.Sprintf("%d", int(pl3v)), + force: w.limits.ForceRequired(maxPL), + } } - watts := fmt.Sprintf("%d", int(w.tdpBasicScale.Value())) - _, err := api.SendTdpSet(watts, "", "", "", false) - return err + return tdpRequest{watts: fmt.Sprintf("%d", int(w.tdpBasicScale.Value()))} } -// sendFanCurve sends the current fan curve to the daemon. -func (w *Window) sendFanCurve() error { +// send performs the socket round-trip. Safe to call from a goroutine — it holds +// only plain strings. +func (r tdpRequest) send() error { + return daemon.Err(api.SendTdpSet(r.watts, r.pl1, r.pl2, r.pl3, r.force)) +} + +// readFanCurve snapshots the fan curve as its wire string. Must be called from +// the GTK main thread; returns "" when there is no editor to read. +func (w *Window) readFanCurve() string { if w.fanCurve == nil { + return "" + } + return w.fanCurve.curveString() +} + +// sendFanCurve sends a previously snapshotted curve. Safe from a goroutine. +func sendFanCurve(curve string) error { + if curve == "" { return nil } - _, err := api.SendFanCurveSet(w.fanCurve.curveString()) - return err + return daemon.Err(api.SendFanCurveSet(curve)) } // refreshProfile fetches state and updates the profile button highlight. -func (w *Window) refreshProfile() { - ok, state, err := api.SendGetState() - if ok && err == nil { - glib.IdleAdd(func() { - w.state = state - w.syncing = true - w.syncProfile() - w.syncing = false - }) +// refreshState fetches daemon state and re-syncs both the custom view and the +// profile buttons. Every custom-profile operation uses it rather than syncing the +// profile alone: the fan curve editor's PWM floor is derived from the applied +// PL1, so a TDP change has to re-evaluate the whole view, not just the highlight. +// Safe to call from a background goroutine. +func (w *Window) refreshState() { + ok, state, rawErr := api.SendGetState() + // Report a failed read rather than returning quietly. This runs after a + // successful write, so a failure here means the daemon went away in between + // and everything on screen is now stale — worth saying, since the widgets + // otherwise keep displaying values nothing is honouring. + if err := daemon.Err(ok, rawErr); err != nil { + w.reportError("Read daemon state", err) + return + } + if state == nil { + return } + glib.IdleAdd(func() { + w.state = state + w.syncCustomView() + w.syncing = true + w.syncProfile() + w.syncing = false + }) } // saveCustomTdp commits only the TDP values. func (w *Window) saveCustomTdp() { + req := w.readTdpRequest() // widget reads stay on the GTK thread go func() { - if err := w.sendTdp(); err != nil { - slog.Warn("tdp set failed", "err", err) + if err := req.send(); err != nil { + w.reportError("Save TDP", err) return } + w.clearErrorAsync() slog.Info("custom TDP saved") - w.refreshProfile() + w.refreshState() }() } // saveCustomFanCurve commits only the fan curve. func (w *Window) saveCustomFanCurve() { + curve := w.readFanCurve() // widget read stays on the GTK thread go func() { - if err := w.sendFanCurve(); err != nil { - slog.Warn("fan curve set failed", "err", err) + if err := sendFanCurve(curve); err != nil { + w.reportError("Save fan curve", err) return } + w.clearErrorAsync() slog.Info("custom fan curve saved") - w.refreshProfile() + w.refreshState() }() } // saveCustomBoth commits both TDP and fan curve. func (w *Window) saveCustomBoth() { + req := w.readTdpRequest() // widget reads stay on the GTK thread + curve := w.readFanCurve() go func() { - tdpErr := w.sendTdp() - fanErr := w.sendFanCurve() - if tdpErr != nil { - slog.Warn("tdp set failed", "err", tdpErr) - } - if fanErr != nil { - slog.Warn("fan curve set failed", "err", fanErr) - } - if tdpErr == nil && fanErr == nil { + tdpErr := req.send() + fanErr := sendFanCurve(curve) + switch { + case tdpErr != nil: + // TDP first: a rejected TDP is usually why the fan write failed too + // (the daemon refuses a curve below the 80% floor while PL1 is high). + w.reportError("Save TDP", tdpErr) + case fanErr != nil: + w.reportError("Save fan curve", fanErr) + default: + w.clearErrorAsync() slog.Info("custom profile saved (TDP + fans)") } - w.refreshProfile() + w.refreshState() }() } // resetTdp resets TDP to firmware defaults. func (w *Window) resetTdp() { go func() { - if _, err := api.SendTdpReset(); err != nil { - slog.Warn("tdp reset failed", "err", err) + if err := daemon.Err(api.SendTdpReset()); err != nil { + w.reportError("Reset TDP", err) return } + w.clearErrorAsync() slog.Info("tdp reset to defaults") - ok, state, err := api.SendGetState() - if ok && err == nil { - glib.IdleAdd(func() { - w.state = state - w.syncCustomView() - w.syncing = true - w.syncProfile() - w.syncing = false - }) - } + w.refreshState() }() } // resetFanCurve resets fan curves to firmware auto mode. func (w *Window) resetFanCurve() { go func() { - if _, err := api.SendFanCurveReset(); err != nil { - slog.Warn("fan curve reset failed", "err", err) + if err := daemon.Err(api.SendFanCurveReset()); err != nil { + // The daemon refuses this while sustained TDP is above the safe max — + // firmware auto has no PWM floor. Reset TDP is the way out. + w.reportError("Reset fans", err) return } + w.clearErrorAsync() slog.Info("fan curve reset to auto") - ok, state, err := api.SendGetState() - if ok && err == nil { - glib.IdleAdd(func() { - w.state = state - w.syncCustomView() - w.syncing = true - w.syncProfile() - w.syncing = false - }) - } + w.refreshState() }() } // saveUndervolt commits the current Curve Optimizer offsets to the daemon. func (w *Window) saveUndervolt() { + cpu := fmt.Sprintf("%d", int(w.uvCpuScale.Value())) // GTK thread go func() { - cpu := fmt.Sprintf("%d", int(w.uvCpuScale.Value())) - if _, err := api.SendUndervoltSet(cpu); err != nil { - slog.Warn("undervolt set failed", "err", err) + if err := daemon.Err(api.SendUndervoltSet(cpu)); err != nil { + w.reportError("Save undervolt", err) return } + w.clearErrorAsync() slog.Info("undervolt saved", "cpu", cpu) - w.refreshProfile() + w.refreshState() }() } // resetUndervolt resets Curve Optimizer to stock (0). func (w *Window) resetUndervolt() { go func() { - if _, err := api.SendUndervoltReset(); err != nil { - slog.Warn("undervolt reset failed", "err", err) + if err := daemon.Err(api.SendUndervoltReset()); err != nil { + w.reportError("Reset undervolt", err) return } + w.clearErrorAsync() slog.Info("undervolt reset to stock") - ok, state, err := api.SendGetState() - if ok && err == nil { - glib.IdleAdd(func() { - w.state = state - w.syncCustomView() - w.syncing = true - w.syncProfile() - w.syncing = false - }) - } + w.refreshState() }() } @@ -810,18 +934,33 @@ func (w *Window) startTelemetryPolling() { w.telemetryGen++ gen := w.telemetryGen glib.TimeoutAdd(1000, func() bool { - if gen != w.telemetryGen || !w.visible { + if gen != w.telemetryGen || !w.visible.Load() { return false } + // One request at a time. api commands carry a 10s deadline, so against a + // slow daemon a goroutine per tick meant ten overlapping requests whose + // replies could apply out of order and walk w.state backwards. + if w.telemetryBusy { + slog.Debug("telemetry: skipping tick, request still in flight") + return true + } + w.telemetryBusy = true go func() { ok, state, err := api.SendGetState() - if !ok || err != nil { - return - } glib.IdleAdd(func() { + // Cleared unconditionally, including on the failure paths below: + // leaving it set would stop the poll for good. + w.telemetryBusy = false if gen != w.telemetryGen { return } + // Deliberately silent, unlike every other daemon call: this is a + // background poll the user did not ask for, and reporting it would + // repaint the bar every second, overwriting whatever error they were + // reading. Their next action reports it — see refreshState. + if !ok || err != nil || state == nil { + return + } w.state = state // Header telemetry (visible on all views). @@ -840,6 +979,9 @@ func (w *Window) startTelemetryPolling() { if w.fanCurve != nil { w.fanCurve.area.QueueDraw() } + // The floor line moved with the fresh PL1, so the button that is + // gated on the same value has to follow it. + w.syncFanResetSensitivity() } }) }() @@ -865,7 +1007,7 @@ func (w *Window) buildCustomFocusList() { widget: w.tdpBasicScale, row: 1, col: 0, section: "tdp", editable: true, - onLeft: oL, onRight: oR, + onLeft: oL, onRight: oR, getValue: gV, setValue: sV, isVisible: func() bool { return w.tdpBasicScale.IsVisible() }, }) @@ -889,7 +1031,7 @@ func (w *Window) buildCustomFocusList() { section: "tdp", editable: true, isVisible: advVis, - onLeft: oL, onRight: oR, + onLeft: oL, onRight: oR, getValue: gV, setValue: sV, }) } @@ -897,7 +1039,7 @@ func (w *Window) buildCustomFocusList() { // Row 6: fan curve (editable with custom behavior). if w.fanCurve != nil { items = append(items, focusItem{ - widget: w.fanCurve.area, row: 6, col: 0, + widget: w.fanCurve.area, row: 6, col: 0, section: "fan", // Fan curve is navigable but not editable via gamepad in this first pass. // Touch/mouse drag handles interaction. @@ -954,5 +1096,6 @@ func (w *Window) buildCustomFocusList() { onActivate: func() { w.resetFanBtn.Activate() }, }) + items = append(items, w.errBarFocusItem()) w.customFocusItems = items } diff --git a/internal/gui/theme-default.css b/internal/gui/theme-default.css index 2cca6f1..1c5bc5b 100644 --- a/internal/gui/theme-default.css +++ b/internal/gui/theme-default.css @@ -1,3 +1,6 @@ +/* Copyright 2026 Jeff Hagadorn + SPDX-License-Identifier: Apache-2.0 */ + /* z13gui default theme — ROG dark red. To create a custom theme, copy this file to: $XDG_CONFIG_HOME/z13gui/theme.css (usually ~/.config/z13gui/theme.css) @@ -11,7 +14,11 @@ @z13-text — primary text @z13-text-dim — secondary/label text @z13-border — window border and separator color - @z13-radius — window corner radius (requires GTK 4.12+) + @z13-error — error bar text and border, high-TDP warning text + + Every one of those is defined below, so this file works as a standalone + theme.css. That matters: theme.css is loaded verbatim, unlike theme.toml, + whose values are substituted into a copy of this file at startup. All element-level rules are scoped to .drawer (or window.z13-drawer-window). @@ -25,6 +32,7 @@ @define-color z13-text #e0e0e0; @define-color z13-text-dim #888888; @define-color z13-border #444444; +@define-color z13-error #ff4444; /* Window — transparent background so border-radius from .drawer is visible. */ window.z13-drawer-window { @@ -109,7 +117,17 @@ window.z13-drawer-window { /* TDP warning label */ .tdp-warning { - color: #ff4444; + color: @z13-error; +} + +/* Error bar */ +.error-bar { + background: @z13-surface; + border: 1px solid @z13-error; +} + +.error-bar .error-text { + color: @z13-error; } /* Fan curve drawing area */ @@ -143,60 +161,36 @@ window.z13-drawer-window { border: 1px solid @z13-text; } -/* Bottom bar — palette menu button */ -.bottom-bar menubutton > button { +/* Icon buttons that sit outside the button groups: the bottom bar's theme + picker and the back button in the custom, theme and colour views. These are + the only buttons not covered by .btn-group, and they were previously styled + as `menubutton > button` — a widget the drawer stopped using when popovers + were replaced by stack views, so the rule silently stopped matching and the + theme picker button fell back to the stock GTK button colours. */ +.bottom-bar button, +.view-back-btn { background: @z13-surface; color: @z13-text; border: 1px solid @z13-border; } -.bottom-bar menubutton > button:hover { +.bottom-bar button:hover, +.view-back-btn:hover { background: @z13-surface-alt; } -/* Popovers (theme picker, color chooser) — not inside .drawer */ -popover.z13-popover > contents { - background: @z13-surface; - color: @z13-text; - border: 1px solid @z13-border; - border-radius: 8px; -} - -/* Checkbuttons inside popovers (theme radio list) */ -popover.z13-popover checkbutton { - color: @z13-text; - background: @z13-bg; +/* Error bar dismiss — transparent rather than surface-coloured, since the bar + itself is @z13-surface and a filled button on it would disappear. */ +.error-bar .error-dismiss { + background: transparent; + color: @z13-error; + border: none; } -popover.z13-popover checkbutton:hover { +.error-bar .error-dismiss:hover { background: @z13-surface-alt; } -popover.z13-popover checkbutton:checked { - background: @z13-accent; - color: #ffffff; -} - -popover.z13-popover checkbutton indicator { - background: @z13-bg; - border: 1px solid @z13-border; - border-radius: 50%; -} - -popover.z13-popover checkbutton:checked indicator { - background: @z13-accent; - border-color: @z13-accent; -} - -/* Labels inside popovers */ -popover.z13-popover .section-label { - color: @z13-text-dim; -} - -popover.z13-popover .accent-label { - color: @z13-text-dim; -} - /* Active accent dot — theme-aware border (visible on both dark and light) */ .accent-dot-active { border-color: @z13-text; diff --git a/internal/gui/theme-default.toml b/internal/gui/theme-default.toml index 8ad9b8b..0f812fb 100644 --- a/internal/gui/theme-default.toml +++ b/internal/gui/theme-default.toml @@ -25,3 +25,7 @@ text_dim = "#888888" # Border color — window outline and separators border = "#444444" + +# Error color — error bar text and border, high-TDP warning text. +# Omitting this key keeps the default, so older theme.toml files still work. +error = "#ff4444" diff --git a/internal/keyrepeat/keyrepeat.go b/internal/keyrepeat/keyrepeat.go new file mode 100644 index 0000000..46a6ffd --- /dev/null +++ b/internal/keyrepeat/keyrepeat.go @@ -0,0 +1,96 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package keyrepeat decides which held direction owns a gamepad's auto-repeat. +// +// Holding a D-pad direction repeats it: one press, then a burst until release. +// The bookkeeping looks trivial and is not, because the events driving it are +// independent. Two directions can be held at once, a button-style D-pad reports +// each direction separately while a hat-axis one reports a shared axis returning +// to centre, and a timer that has already fired can still be running when the +// next press arrives. Getting it wrong produces a direction that repeats forever +// with nothing held, which is indistinguishable from a stuck controller. +// +// Two defects this pins down, both live in z13gui before it existed: +// +// - Switching direction mid-repeat could leave the *old* direction repeating. +// The in-flight timer callback re-armed itself after checking only that some +// timer existed, so it overwrote the new direction's timer with its own. +// - Releasing one held direction cancelled another still being held, because +// the stop was unconditional and did not ask who owned the repeat. +// +// It is a separate package because internal/gui/gamepad is excluded from +// `make test` by path, so anything left in there cannot be verified — and this +// is exactly the sort of index-and-ownership bookkeeping that needs to be. +// +// Timers live in the caller: this type only answers "who owns the repeat now" +// and "is this callback still current". A Tracker is not internally +// synchronized; the caller holds its own lock across these calls, since the +// answers are only meaningful together with the timer they describe. +package keyrepeat + +// Tracker records which action currently owns the auto-repeat, and hands out a +// generation number so a timer callback can tell whether it is still the one in +// charge. +// +// The zero value is a valid Tracker with no repeat active. +type Tracker[A comparable] struct { + active bool + action A + gen uint64 +} + +// Start takes ownership of the repeat for action and returns the generation the +// caller's timer callback must quote to ReArm. +// +// Every Start invalidates the previous generation, so a timer that fired just +// before this call cannot re-arm itself afterwards. That is the whole point: the +// old callback is still going to run, and it has to become a no-op rather than +// resurrect the direction the user has already let go of. +func (t *Tracker[A]) Start(action A) uint64 { + t.gen++ + t.active = true + t.action = action + return t.gen +} + +// ReArm reports whether a timer callback holding gen is still the current +// repeat, and therefore whether it should schedule its next tick. +func (t *Tracker[A]) ReArm(gen uint64) bool { + return t.active && gen == t.gen +} + +// Stop ends the repeat and reports whether there was one to end. +// +// With no arguments it stops whatever is active — the right behaviour when the +// reader is shutting down or focus is going away. Given one or more actions it +// stops only if the repeat belongs to one of them, so releasing Left leaves a +// still-held Up repeating. Pass the actions that share the physical control: for +// a hat axis returning to centre that is the two directions on that axis, since +// the event says "this axis is centred" and nothing about the other one. +func (t *Tracker[A]) Stop(only ...A) bool { + if !t.active { + return false + } + if len(only) > 0 { + var owned bool + for _, a := range only { + if a == t.action { + owned = true + break + } + } + if !owned { + return false + } + } + t.gen++ + t.active = false + return true +} + +// Active reports whether a repeat is currently running. +func (t *Tracker[A]) Active() bool { return t.active } + +// Action returns the action that owns the repeat. Only meaningful while Active. +func (t *Tracker[A]) Action() A { return t.action } diff --git a/internal/keyrepeat/keyrepeat_test.go b/internal/keyrepeat/keyrepeat_test.go new file mode 100644 index 0000000..889ef5e --- /dev/null +++ b/internal/keyrepeat/keyrepeat_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package keyrepeat + +import "testing" + +// Directions, standing in for gamepad.Action. +type dir int + +const ( + up dir = iota + down + left + right +) + +func TestZeroValueHasNoRepeat(t *testing.T) { + var tr Tracker[dir] + if tr.Active() { + t.Error("zero value reports an active repeat") + } + if tr.Stop() { + t.Error("Stop on a fresh Tracker reported it stopped something") + } + if tr.ReArm(0) { + t.Error("ReArm succeeded with no repeat active") + } +} + +func TestStartThenReArm(t *testing.T) { + var tr Tracker[dir] + gen := tr.Start(up) + if !tr.Active() || tr.Action() != up { + t.Fatalf("Active=%v Action=%v, want true/up", tr.Active(), tr.Action()) + } + if !tr.ReArm(gen) { + t.Error("the current generation may not re-arm") + } + // Re-arming does not consume the generation: a repeat ticks many times. + if !tr.ReArm(gen) { + t.Error("second ReArm with the same generation failed") + } +} + +func TestStopPreventsReArm(t *testing.T) { + var tr Tracker[dir] + gen := tr.Start(up) + if !tr.Stop() { + t.Error("Stop reported nothing to stop") + } + if tr.Active() { + t.Error("still active after Stop") + } + // The already-fired timer callback must become a no-op rather than keep + // scheduling itself forever. + if tr.ReArm(gen) { + t.Error("a stopped generation was allowed to re-arm — direction would repeat forever") + } +} + +// The first of the two bugs: hold Up, then press Right without releasing Up. The +// Up timer has already fired and is about to re-arm. It must not, and it must not +// displace Right. +func TestSwitchingDirectionRetiresTheOldGeneration(t *testing.T) { + var tr Tracker[dir] + upGen := tr.Start(up) + + // ... Up's timer fires here and is now running, holding upGen ... + + rightGen := tr.Start(right) + + if tr.ReArm(upGen) { + t.Error("the superseded Up callback was allowed to re-arm: Up would keep " + + "repeating and Right's timer would be dropped") + } + if !tr.ReArm(rightGen) { + t.Error("the current Right callback may not re-arm") + } + if tr.Action() != right { + t.Errorf("Action = %v, want right", tr.Action()) + } +} + +// The second bug: two directions held at once, one released. The release names +// only the direction it belongs to, so the other must survive. +func TestStopOnlyAffectsTheOwningAction(t *testing.T) { + var tr Tracker[dir] + gen := tr.Start(up) + + if tr.Stop(left) { + t.Error("releasing Left stopped a repeat owned by Up") + } + if !tr.Active() || tr.Action() != up { + t.Error("Up's repeat did not survive an unrelated release") + } + if !tr.ReArm(gen) { + t.Error("Up may no longer re-arm after an unrelated release") + } + + if !tr.Stop(up) { + t.Error("releasing Up did not stop Up's own repeat") + } + if tr.Active() { + t.Error("still active after the owning action was released") + } +} + +// A hat axis returning to centre reports only that axis, so it stops either of +// the two directions on it and leaves the other axis alone. +func TestStopWithAxisPair(t *testing.T) { + yAxis := []dir{up, down} + xAxis := []dir{left, right} + + t.Run("centring the other axis leaves the repeat", func(t *testing.T) { + var tr Tracker[dir] + tr.Start(down) + if tr.Stop(xAxis...) { + t.Error("X axis centring stopped a repeat owned by Down") + } + if !tr.Active() { + t.Error("Down's repeat was lost") + } + }) + + t.Run("centring the owning axis stops it", func(t *testing.T) { + var tr Tracker[dir] + tr.Start(down) + if !tr.Stop(yAxis...) { + t.Error("Y axis centring did not stop Down") + } + if tr.Active() { + t.Error("still active after its own axis centred") + } + }) +} + +func TestStopWithNoArgumentsStopsAnything(t *testing.T) { + for _, d := range []dir{up, down, left, right} { + var tr Tracker[dir] + tr.Start(d) + if !tr.Stop() { + t.Errorf("bare Stop did not stop a repeat owned by %v", d) + } + if tr.Active() { + t.Errorf("%v still active after bare Stop", d) + } + } +} + +// Generations must never be reused, or a long-lived stale callback could match a +// later one by coincidence and resurrect a direction nothing is holding. +func TestGenerationsAreNeverReused(t *testing.T) { + var tr Tracker[dir] + seen := map[uint64]bool{} + for i := 0; i < 100; i++ { + gen := tr.Start(up) + if seen[gen] { + t.Fatalf("generation %d reused on iteration %d", gen, i) + } + seen[gen] = true + tr.Stop() + } +} + +// Stop must also burn a generation. Otherwise stop-then-start could hand out the +// same number a callback from before the stop is still holding. +func TestStopAdvancesTheGeneration(t *testing.T) { + var tr Tracker[dir] + first := tr.Start(up) + tr.Stop() + second := tr.Start(up) + if first == second { + t.Fatal("Start after Stop reissued the same generation") + } + if tr.ReArm(first) { + t.Error("the pre-Stop generation may not re-arm") + } + if !tr.ReArm(second) { + t.Error("the current generation may not re-arm") + } +} diff --git a/internal/lighting/lighting.go b/internal/lighting/lighting.go new file mode 100644 index 0000000..2f6186a --- /dev/null +++ b/internal/lighting/lighting.go @@ -0,0 +1,136 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package lighting holds the drawer's RGB lighting rules: which mode a daemon +// state represents, which controls that mode needs, and what to fall back to when +// state is missing. +// +// Separate from internal/gui because that package needs CGO and GTK4 headers and +// cannot be unit tested. These are decisions about daemon state, not widgets. +package lighting + +import "github.com/dahui/z13ctl/api" + +// Defaults used when daemon state is unavailable — before the first sync, or when +// the daemon is not running. +const ( + DefaultColor1 = "FF0000" + DefaultColor2 = "000000" + DefaultMode = "static" + DefaultSpeed = "normal" + DefaultBrightness = 3 + + // ModeOff is the drawer's pseudo-mode for "lighting disabled". The daemon + // represents this as Enabled=false, and the drawer needs a selectable button + // for it. + // + // Note the daemon does not preserve the rest of the entry on a per-zone off, + // which is the only kind the drawer issues: it stores + // LightingState{Enabled: false} with mode, colours, speed and brightness all + // zeroed. So re-enabling cannot restore the previous effect, and every field + // read out of a disabled state needs a fallback — see ResolveBrightness for + // what happens when one does not have it. + ModeOff = "off" +) + +// Controls says which of the lighting sub-controls apply to a mode. A mode that +// does not animate has no speed; one that ignores colour has no swatches. +type Controls struct { + Color1 bool + Color2 bool + Speed bool + Brightness bool +} + +// modeControls is the per-mode table. Unknown modes are handled by ControlsFor. +var modeControls = map[string]Controls{ + "static": {Color1: true, Brightness: true}, + "breathe": {Color1: true, Color2: true, Speed: true, Brightness: true}, + "cycle": {Speed: true, Brightness: true}, + "rainbow": {Speed: true, Brightness: true}, + "strobe": {Color1: true, Speed: true, Brightness: true}, + ModeOff: {}, +} + +// ControlsFor returns the controls a mode needs. +// +// An unrecognised mode shows everything. A newer daemon may know modes this build +// does not, and revealing all the controls lets the user still operate them; +// hiding them would make the mode look broken. +func ControlsFor(mode string) Controls { + if c, ok := modeControls[mode]; ok { + return c + } + return Controls{Color1: true, Color2: true, Speed: true, Brightness: true} +} + +// KnownMode reports whether mode is one this build has a control layout for. +func KnownMode(mode string) bool { + _, ok := modeControls[mode] + return ok +} + +// ResolveMode returns the mode button the drawer should select for a lighting +// state. +// +// Disabled lighting selects ModeOff regardless of any mode the daemon still has +// recorded: showing "breathe" as active while the keyboard is dark would be a lie. +// +// An enabled state with no mode falls back to the default rather than selecting +// nothing: the daemon can legitimately store a partial per-zone entry, which is +// what made zone lighting come back blank after a reboot. +func ResolveMode(ls api.LightingState) string { + if !ls.Enabled { + return ModeOff + } + if ls.Mode == "" { + return DefaultMode + } + return ls.Mode +} + +// ResolveSpeed returns the speed to select, falling back when unset. +func ResolveSpeed(ls api.LightingState) string { + if ls.Speed == "" { + return DefaultSpeed + } + return ls.Speed +} + +// ResolveBrightness returns the brightness the slider should show. +// +// A disabled state carries no meaningful brightness, so it reports the default +// rather than the stored value. The daemon's per-zone off replaces the whole entry +// with LightingState{Enabled: false} — every other field zeroed — so the stored +// value is 0, and 0 is the hardware's "off" level, not merely a dim one. +// +// Without this, turning a zone off and then back on left the keyboard dark: the +// slider adopted the zero, the next apply sent brightness 0, and the daemon +// dutifully set the backlight to off while reporting success. The mode button lit +// up and nothing else happened. +// +// A zero brightness on an *enabled* state is passed through, since that is a +// setting the user can deliberately choose with the slider, and second-guessing it +// would misreport the hardware. This is the same partial-state problem ResolveMode +// and ResolveSpeed already guard against; brightness was simply missed. +func ResolveBrightness(ls api.LightingState) int { + if !ls.Enabled { + return DefaultBrightness + } + return ls.Brightness +} + +// StateForZone picks the lighting state to display for a zone, preferring the +// per-device entry and falling back to the global one. +// +// Returns the zero state when nothing is available, which ResolveMode reads as +// disabled — the correct thing to show when the daemon has told us nothing. +func StateForZone(s *api.State, zone string) api.LightingState { + if s == nil { + return api.LightingState{} + } + if dev, ok := s.Devices[zone]; ok { + return dev + } + return s.Lighting +} diff --git a/internal/lighting/lighting_test.go b/internal/lighting/lighting_test.go new file mode 100644 index 0000000..552312a --- /dev/null +++ b/internal/lighting/lighting_test.go @@ -0,0 +1,266 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package lighting + +import ( + "testing" + + "github.com/dahui/z13ctl/api" +) + +func TestResolveMode(t *testing.T) { + tests := []struct { + name string + ls api.LightingState + want string + }{ + { + // The daemon keeps the previous mode recorded while disabled so that + // re-enabling restores it. The drawer must still show "off" — showing + // "breathe" as active while the keyboard is dark is a lie, and it is + // what made a disabled zone come back looking enabled after a reboot. + name: "disabled keeps its mode but shows off", + ls: api.LightingState{Enabled: false, Mode: "breathe"}, + want: ModeOff, + }, + { + name: "disabled with no mode", + ls: api.LightingState{Enabled: false}, + want: ModeOff, + }, + { + name: "enabled uses its mode", + ls: api.LightingState{Enabled: true, Mode: "cycle"}, + want: "cycle", + }, + { + // A per-zone entry can legitimately be stored with only some fields + // set, which used to leave no mode button selected at all. + name: "enabled with no mode falls back to the default", + ls: api.LightingState{Enabled: true}, + want: DefaultMode, + }, + { + // A newer daemon may know modes this build does not; pass them through + // rather than substituting a default the user did not choose. + name: "unknown mode passes through when enabled", + ls: api.LightingState{Enabled: true, Mode: "future-effect"}, + want: "future-effect", + }, + { + name: "zero state reads as off", + ls: api.LightingState{}, + want: ModeOff, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveMode(tt.ls); got != tt.want { + t.Errorf("ResolveMode(%+v) = %q, want %q", tt.ls, got, tt.want) + } + }) + } +} + +func TestResolveSpeed(t *testing.T) { + if got := ResolveSpeed(api.LightingState{Speed: "fast"}); got != "fast" { + t.Errorf("ResolveSpeed = %q, want fast", got) + } + if got := ResolveSpeed(api.LightingState{}); got != DefaultSpeed { + t.Errorf("ResolveSpeed with no speed = %q, want %q", got, DefaultSpeed) + } +} + +func TestControlsForKnownModes(t *testing.T) { + tests := []struct { + mode string + want Controls + }{ + {mode: "static", want: Controls{Color1: true, Brightness: true}}, + {mode: "breathe", want: Controls{Color1: true, Color2: true, Speed: true, Brightness: true}}, + {mode: "cycle", want: Controls{Speed: true, Brightness: true}}, + {mode: "rainbow", want: Controls{Speed: true, Brightness: true}}, + {mode: "strobe", want: Controls{Color1: true, Speed: true, Brightness: true}}, + {mode: ModeOff, want: Controls{}}, + } + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + if got := ControlsFor(tt.mode); got != tt.want { + t.Errorf("ControlsFor(%q) = %+v, want %+v", tt.mode, got, tt.want) + } + }) + } +} + +// Hiding every control for a mode this build does not recognise would make it look +// broken; a newer daemon's mode should still be operable. +func TestControlsForUnknownModeShowsEverything(t *testing.T) { + got := ControlsFor("future-effect") + want := Controls{Color1: true, Color2: true, Speed: true, Brightness: true} + if got != want { + t.Errorf("ControlsFor(unknown) = %+v, want %+v", got, want) + } + if ControlsFor("") != want { + t.Errorf("ControlsFor(empty) should also show everything") + } +} + +// Off must hide everything: leaving a colour swatch or the brightness slider live +// while lighting is disabled invites edits that cannot take effect. +func TestOffHidesEveryControl(t *testing.T) { + if got := ControlsFor(ModeOff); got != (Controls{}) { + t.Errorf("ControlsFor(off) = %+v, want all false", got) + } +} + +// Every mode that animates needs a speed control and vice versa — the table is +// hand-maintained, so tie the two together. +func TestAnimatedModesHaveSpeed(t *testing.T) { + animated := map[string]bool{"breathe": true, "cycle": true, "rainbow": true, "strobe": true} + for mode, c := range modeControls { + if animated[mode] != c.Speed { + t.Errorf("mode %q: animated=%v but Speed=%v", mode, animated[mode], c.Speed) + } + } +} + +// Anything other than off should keep brightness available, or the user loses the +// only control that always applies. +func TestEveryVisibleModeKeepsBrightness(t *testing.T) { + for mode, c := range modeControls { + if mode == ModeOff { + continue + } + if !c.Brightness { + t.Errorf("mode %q has no brightness control", mode) + } + } +} + +func TestKnownMode(t *testing.T) { + for _, mode := range []string{"static", "breathe", "cycle", "rainbow", "strobe", ModeOff} { + if !KnownMode(mode) { + t.Errorf("KnownMode(%q) = false, want true", mode) + } + } + for _, mode := range []string{"", "future-effect", "STATIC"} { + if KnownMode(mode) { + t.Errorf("KnownMode(%q) = true, want false", mode) + } + } +} + +func TestStateForZone(t *testing.T) { + global := api.LightingState{Enabled: true, Mode: "static", Color: "FF0000"} + kb := api.LightingState{Enabled: true, Mode: "breathe", Color: "00FF00"} + + s := &api.State{ + Lighting: global, + Devices: map[string]api.LightingState{"keyboard": kb}, + } + + if got := StateForZone(s, "keyboard"); got.Mode != "breathe" { + t.Errorf("keyboard zone = %+v, want the per-device entry", got) + } + // A zone with no per-device entry falls back to the global state. + if got := StateForZone(s, "lightbar"); got.Mode != "static" { + t.Errorf("lightbar zone = %+v, want the global entry", got) + } + // Nil state must not panic, and must read as disabled. + got := StateForZone(nil, "keyboard") + if got != (api.LightingState{}) { + t.Errorf("StateForZone(nil) = %+v, want the zero state", got) + } + if ResolveMode(got) != ModeOff { + t.Error("the zero state should resolve to off") + } +} + +// A per-device entry stored as disabled must win over an enabled global one, or +// turning off one zone would appear to have failed. +func TestStateForZonePrefersADisabledDeviceEntry(t *testing.T) { + s := &api.State{ + Lighting: api.LightingState{Enabled: true, Mode: "static"}, + Devices: map[string]api.LightingState{"lightbar": {Enabled: false}}, + } + if got := ResolveMode(StateForZone(s, "lightbar")); got != ModeOff { + t.Errorf("disabled lightbar resolved to %q, want off", got) + } +} + +func TestResolveBrightness(t *testing.T) { + tests := []struct { + name string + ls api.LightingState + want int + }{ + { + // The exact state z13ctl's per-zone off writes: LightingState{Enabled: + // false}, every other field zeroed. Adopting the 0 sent brightness 0 on + // the next apply, which is the hardware's off level — so re-enabling a + // zone left the keyboard dark while reporting success. + name: "disabled with everything zeroed", + ls: api.LightingState{Enabled: false}, + want: DefaultBrightness, + }, + { + // Full off preserves the rest of the global entry, so the stored value is + // real — but it still must not be adopted while disabled, or the same + // trap applies whenever that value happens to be 0. + name: "disabled with a stored brightness", + ls: api.LightingState{Enabled: false, Mode: "breathe", Brightness: 0}, + want: DefaultBrightness, + }, + { + name: "disabled with a non-zero stored brightness", + ls: api.LightingState{Enabled: false, Brightness: 2}, + want: DefaultBrightness, + }, + { + name: "enabled passes its value through", + ls: api.LightingState{Enabled: true, Brightness: 2}, + want: 2, + }, + { + name: "enabled at maximum", + ls: api.LightingState{Enabled: true, Brightness: 3}, + want: 3, + }, + { + // A deliberate slider choice on an enabled zone. Substituting here would + // misreport the hardware, which is the opposite failure. + name: "enabled at zero is the user's choice", + ls: api.LightingState{Enabled: true, Brightness: 0}, + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveBrightness(tt.ls); got != tt.want { + t.Errorf("ResolveBrightness(%+v) = %d, want %d", tt.ls, got, tt.want) + } + }) + } +} + +// TestResolveBrightnessNeverDarkensAnOffZone is the property that matters: for any +// state the drawer shows as off, the slider must offer a level that actually lights +// the keyboard when the user picks a mode. +func TestResolveBrightnessNeverDarkensAnOffZone(t *testing.T) { + for stored := -1; stored <= 4; stored++ { + ls := api.LightingState{Enabled: false, Brightness: stored} + if got := ResolveBrightness(ls); got <= 0 { + t.Errorf("stored %d: ResolveBrightness = %d, which leaves the zone dark "+ + "when re-enabled", stored, got) + } + } +} + +// The zero LightingState is what StateForZone returns when the daemon has told us +// nothing, so it goes down the same path. +func TestResolveBrightnessOfZeroValue(t *testing.T) { + if got := ResolveBrightness(api.LightingState{}); got != DefaultBrightness { + t.Errorf("ResolveBrightness(zero) = %d, want %d", got, DefaultBrightness) + } +} diff --git a/internal/power/power.go b/internal/power/power.go new file mode 100644 index 0000000..000bc9c --- /dev/null +++ b/internal/power/power.go @@ -0,0 +1,390 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package power holds the TDP and fan-curve rules the drawer needs in order to +// avoid offering the user a state the z13ctl daemon would refuse. +// +// It exists to be testable. The GTK code in internal/gui cannot be unit tested +// without CGO and GTK4 headers, so everything here is pure Go operating on plain +// values — no widgets, no daemon calls. internal/gui holds the widgets and +// delegates every decision to this package. +// +// # Device limits +// +// The numbers live in a Limits value rather than in package constants, because +// z13ctl is being extended to other AMD devices whose chips have different power +// limits and per-profile PPT defaults. DefaultLimits returns the 2025 Flow Z13's +// values, which are correct for the only device supported today. +// +// The daemon does not yet serve its limits over the API — they live in z13ctl's +// internal/cli, which is not exported through the api module, so they have to be +// duplicated here for now. When that API lands the only change is where the +// Limits value comes from: fetch once at startup, Sanitized, falling back to +// DefaultLimits. Nothing else in the drawer moves. See the design brief in +// z13ctl's .claude/plans/device-limits-api.md. +// +// If the two ever disagree the daemon wins: it validates against hardware, and +// these rules only exist so the UI does not present an option that gets rejected. +package power + +import ( + "fmt" + "strings" + + "github.com/dahui/z13ctl/api" +) + +// ProfileCustom is the daemon's virtual profile name for user-defined TDP, fan +// curve and undervolt settings. The stock profiles are firmware-managed. +const ProfileCustom = "custom" + +// PWM bounds. Unlike the TDP limits these are the hwmon interface's own range, +// not a device characteristic. +const ( + PWMMin = 0 + PWMMax = 255 +) + +// Fan pwm_enable modes as reported by sysfs and passed through by the daemon's +// get-state. Note these are the raw hwmon values, not the 0=auto/1=custom +// shorthand the api.FanCurveState doc comment suggests. +const ( + FanModeFullSpeed = 0 + FanModeCustom = 1 + FanModeAuto = 2 +) + +// CurvePoints is the number of points in a fan curve. It is fixed at 8 because +// Curve is a fixed-size array; if a future device needs a different count this +// becomes a Limits field and Curve becomes a slice, losing the compile-time +// length guarantee. Worth deciding deliberately rather than by accident. +const CurvePoints = 8 + +// Limits describes one device's power and thermal envelope — everything the +// drawer needs that varies with the hardware. +// +// Presentation policy is deliberately not in here. BasicSliderMax is a method +// rather than a field because "cap the simple slider a little under the safe max" +// is the drawer's choice; only the safe max itself is a device fact. +type Limits struct { + Model string // e.g. "GZ302EA"; for logs and bug reports + TDPMin int // absolute minimum sustained limit + TDPMaxSafe int // above this the daemon requires the force flag + TDPMaxForced int // absolute hardware maximum + HighTDPMinPWM int // fan floor while sustained TDP exceeds TDPMaxSafe; 0 = none + TempMin int // fan curve temperature axis, Celsius + TempMax int + + // StockProfilePPT holds each stock profile's firmware PPT defaults, used to + // tell "the firmware's numbers" from "numbers the user chose". Only the three + // limits the drawer displays are listed; the daemon also tracks APU/Platform + // sPPT, which it mirrors from PL2 and which no UI shows. + StockProfilePPT map[string]api.TDPState +} + +// DefaultLimits returns the 2025 ROG Flow Z13 (GZ302) values. These mirror +// z13ctl's cli.TDPMin / TDPMaxSafe / TDPMaxForced / HighTDPMinPWM and +// cli.StockProfilePPT. +func DefaultLimits() Limits { + return Limits{ + Model: "GZ302", + TDPMin: 5, + TDPMaxSafe: 75, + TDPMaxForced: 93, + HighTDPMinPWM: 204, // 80% of PWMMax + TempMin: 35, + TempMax: 105, + StockProfilePPT: map[string]api.TDPState{ + "quiet": {PL1SPL: 40, PL2SPPT: 55, FPPT: 55}, + "balanced": {PL1SPL: 52, PL2SPPT: 71, FPPT: 70}, + "performance": {PL1SPL: 70, PL2SPPT: 86, FPPT: 86}, + }, + } +} + +// Sanitized returns l with any unset field replaced by its default. +// +// This is the guard for the day the daemon serves limits over the API: a client +// newer than the daemon receives zero for fields the daemon does not know about, +// and a zero TDPMaxSafe would make every fan curve fail the floor check and every +// TDP request demand the force flag. Falling back per-field degrades gracefully +// instead of catastrophically. +// +// HighTDPMinPWM is deliberately not defaulted — zero is a legitimate value there, +// meaning a device with no fan floor at all. +func (l Limits) Sanitized() Limits { + d := DefaultLimits() + if l.Model == "" { + l.Model = d.Model + } + if l.TDPMin <= 0 { + l.TDPMin = d.TDPMin + } + if l.TDPMaxSafe <= 0 { + l.TDPMaxSafe = d.TDPMaxSafe + } + if l.TDPMaxForced <= 0 { + l.TDPMaxForced = d.TDPMaxForced + } + if l.TempMin <= 0 { + l.TempMin = d.TempMin + } + if l.TempMax <= 0 { + l.TempMax = d.TempMax + } + if len(l.StockProfilePPT) == 0 { + l.StockProfilePPT = d.StockProfilePPT + } + + // Ordering and width invariants, not just presence. A per-field default fixes + // a value the daemon never sent; these catch values it sent that cannot be + // true together, which a zero check cannot see. + // + // Each group falls back whole rather than nudging one field, because an + // inconsistent triple does not say which of its members is the wrong one. + if l.TDPMin >= l.TDPMaxSafe || l.TDPMaxSafe > l.TDPMaxForced { + l.TDPMin, l.TDPMaxSafe, l.TDPMaxForced = d.TDPMin, d.TDPMaxSafe, d.TDPMaxForced + } + + // The temperature axis has to be wide enough for the curve's points to hold + // strictly increasing temperatures. Below that EnforceCurve cannot satisfy + // both monotonicity and the bounds, and emits points under TempMin that the + // daemon rejects; at TempMin == TempMax the editor's coordinate mapping + // divides by zero and every point lands on a NaN. The invariant was asserted + // in the tests but never enforced, so it held only for limits compiled in. + if l.TempMax-l.TempMin < CurvePoints-1 { + l.TempMin, l.TempMax = d.TempMin, d.TempMax + } + + // A floor above the hwmon maximum would clamp every curve point to full speed. + if l.HighTDPMinPWM < PWMMin { + l.HighTDPMinPWM = PWMMin + } + if l.HighTDPMinPWM > PWMMax { + l.HighTDPMinPWM = PWMMax + } + return l +} + +// BasicSliderMax is the ceiling of the drawer's single-slider basic view. +// +// Presentation policy, but derived rather than fixed: it only means anything as +// "a little under the safe max". A hardcoded 70 would be nonsense on a device +// whose safe sustained limit is 54. +func (l Limits) BasicSliderMax() int { + const headroom = 5 + if m := l.TDPMaxSafe - headroom; m > l.TDPMin { + return m + } + return l.TDPMin +} + +// ForceRequired reports whether a TDP request needs the force flag, which the +// daemon demands for a sustained limit above TDPMaxSafe. +func (l Limits) ForceRequired(pl1 int) bool { + return pl1 > l.TDPMaxSafe +} + +// FanFloorPWM returns the minimum fan PWM the daemon will accept for a curve +// point given the applied sustained limit, or PWMMin when unconstrained. +// +// While pl1 is above TDPMaxSafe the daemon rejects any curve containing a point +// below HighTDPMinPWM, and refuses a fan reset outright — firmware auto has no +// floor at all, so releasing the fans there would remove the very protection the +// power limit requires. Resetting the TDP is the way back out. +func (l Limits) FanFloorPWM(pl1 int) int { + if pl1 <= l.TDPMaxSafe { + return PWMMin + } + return l.HighTDPMinPWM +} + +// IsStockPPT reports whether a TDP reading matches some stock profile's firmware +// defaults exactly, meaning the user has not diverged from what the firmware +// would set on its own. +// +// An exact match on a genuinely user-chosen triple is possible but harmless: it +// only means the drawer offers the basic view for values the basic view would +// reproduce unchanged. +func (l Limits) IsStockPPT(t api.TDPState) bool { + for _, s := range l.StockProfilePPT { + if t.PL1SPL == s.PL1SPL && t.PL2SPPT == s.PL2SPPT && t.FPPT == s.FPPT { + return true + } + } + return false +} + +// NeedsAdvanced reports whether a TDP state can only be shown accurately in the +// drawer's advanced view. +// +// Basic mode is a single slider that applies one value to all three power limits +// and stops at BasicSliderMax, so it cannot represent either a sustained limit +// above that ceiling or a state where the three limits differ. Showing such a +// state in basic mode would clamp the slider and misreport the hardware — and +// worse, a subsequent save would send the clamped value and quietly lower the +// limit. +// +// Only settings the user actually chose count, which takes two checks rather than +// one. On a stock profile the daemon reports that profile's own PPT defaults, +// whose limits legitimately differ — balanced is 52/71/70 — so the profile must +// be custom. But saving a fan curve or an undervolt is enough to flip the daemon +// to the custom profile on its own, leaving the power limits at the firmware's +// values, so the reading must also differ from the stock defaults. +// +// A basic save round-trips as PL1 == PL2 == FPPT, because the daemon defaults the +// blank PL fields to the single value, so an equal triple never trips this. +func (l Limits) NeedsAdvanced(profile string, t api.TDPState) bool { + if profile != ProfileCustom || l.IsStockPPT(t) { + return false + } + if t.PL1SPL > l.BasicSliderMax() { + return true + } + return t.PL1SPL != t.PL2SPPT || t.PL2SPPT != t.FPPT +} + +// FanCurveIsCustom reports whether a fan curve reported by the daemon is actually +// in force, and therefore worth displaying. +// +// The curve registers keep the last written points even after the fans are +// released to firmware auto, so the points alone cannot tell you anything: +// switching to a stock profile resets the mode to FanModeAuto but leaves the old +// custom points perfectly readable. Drawing them then shows the user a curve the +// firmware is not following. +func FanCurveIsCustom(fc *api.FanCurveState) bool { + return fc != nil && fc.Mode == FanModeCustom && len(fc.Points) == CurvePoints +} + +// Curve is an 8-point fan curve, ordered by ascending temperature. +type Curve [CurvePoints]api.FanCurvePoint + +// DefaultCurve returns the curve shown before the daemon reports one, fitted to +// this device's temperature range. +// +// The shape is hand-tuned for the Z13 and is returned unchanged there. On a +// device with a narrower range EnforceCurve pulls it into bounds; the result is +// no longer hand-tuned, but it is valid, which is what matters for a placeholder. +func (l Limits) DefaultCurve() Curve { + c := Curve{ + {Temp: 35, PWM: 0}, + {Temp: 45, PWM: 25}, + {Temp: 50, PWM: 50}, + {Temp: 60, PWM: 80}, + {Temp: 70, PWM: 120}, + {Temp: 80, PWM: 170}, + {Temp: 90, PWM: 220}, + {Temp: 100, PWM: 255}, + } + l.EnforceCurve(&c, 0, PWMMin) + return c +} + +// String renders the curve in the daemon's "temp:pwm,temp:pwm,..." wire format. +func (c Curve) String() string { + parts := make([]string, 0, len(c)) + for _, p := range c { + parts = append(parts, fmt.Sprintf("%d:%d", p.Temp, p.PWM)) + } + return strings.Join(parts, ",") +} + +// EnforceCurve repairs the curve after point idx has been moved, so that it +// always satisfies what the firmware and daemon require: +// +// - temperatures strictly increase +// - PWM never decreases +// - every point sits within [minPWM, PWMMax] and [TempMin, TempMax] +// +// minPWM comes from FanFloorPWM. Passing the floor in rather than deriving it +// here keeps the rule in one place and makes the clamping directly testable at +// both floor settings. +// +// The moved point is clamped first so it wins over its neighbours, then the +// change cascades outward in both directions, then a final pass re-clamps +// everything — cascading can push a neighbour past a bound. +// +// The moved point's temperature is clamped into a range that leaves room for the +// points on either side: each of the idx points below it needs at least one +// degree, as does each of the points above. Clamping it to the raw TempMin +// instead would push its left-hand neighbours below the minimum, and the final +// clamp would then pile them all onto TempMin — producing duplicate temperatures +// that are not strictly increasing. +func (l Limits) EnforceCurve(c *Curve, idx, minPWM int) { + if idx < 0 || idx >= len(c) { + return + } + clamp := func(p *api.FanCurvePoint) { + if p.Temp < l.TempMin { + p.Temp = l.TempMin + } + if p.Temp > l.TempMax { + p.Temp = l.TempMax + } + if p.PWM < minPWM { + p.PWM = minPWM + } + if p.PWM > PWMMax { + p.PWM = PWMMax + } + } + + clamp(&c[idx]) + if lo := l.TempMin + idx; c[idx].Temp < lo { + c[idx].Temp = lo + } + if hi := l.TempMax - (len(c) - 1 - idx); c[idx].Temp > hi { + c[idx].Temp = hi + } + + // Temperatures must strictly increase. + for i := idx + 1; i < len(c); i++ { + if c[i].Temp <= c[i-1].Temp { + c[i].Temp = c[i-1].Temp + 1 + } + } + for i := idx - 1; i >= 0; i-- { + if c[i].Temp >= c[i+1].Temp { + c[i].Temp = c[i+1].Temp - 1 + } + } + + // PWM must not decrease. + for i := idx + 1; i < len(c); i++ { + if c[i].PWM < c[i-1].PWM { + c[i].PWM = c[i-1].PWM + } + } + for i := idx - 1; i >= 0; i-- { + if c[i].PWM > c[i+1].PWM { + c[i].PWM = c[i+1].PWM + } + } + + for i := range c { + clamp(&c[i]) + } + + // That clamp can collapse several points onto the same bound — a curve + // carried over from a device with a wider temperature range, for instance, + // where every point above the new maximum lands on it. Re-spread so the + // strictly-increasing invariant holds for any input, not just for curves that + // were already valid. Clamping PWM needs no equivalent: a monotone clamp + // preserves ordering. + for i := 1; i < len(c); i++ { + if c[i].Temp <= c[i-1].Temp { + c[i].Temp = c[i-1].Temp + 1 + } + } + // The forward spread can push the tail past the maximum; pull it back from + // the end. There is always room because TempMax-TempMin is at least + // CurvePoints-1 for any sane device. + if last := len(c) - 1; c[last].Temp > l.TempMax { + c[last].Temp = l.TempMax + } + for i := len(c) - 2; i >= 0; i-- { + if c[i].Temp >= c[i+1].Temp { + c[i].Temp = c[i+1].Temp - 1 + } + } +} diff --git a/internal/power/power_test.go b/internal/power/power_test.go new file mode 100644 index 0000000..ce0b6be --- /dev/null +++ b/internal/power/power_test.go @@ -0,0 +1,707 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package power + +import ( + "testing" + + "github.com/dahui/z13ctl/api" +) + +// otherDevice is a fictional second device with a deliberately different +// envelope: a much lower power ceiling, a narrower fan temperature range, no fan +// floor at all, and its own stock profile table. +// +// Every rule is exercised against both it and the Z13 so that nothing quietly +// depends on the Z13's numbers. That is the regression the multi-device port +// would otherwise walk into. +func otherDevice() Limits { + return Limits{ + Model: "FICTIONAL-1", + TDPMin: 4, + TDPMaxSafe: 30, + TDPMaxForced: 45, + HighTDPMinPWM: 0, // this device has no high-TDP fan floor + TempMin: 40, + TempMax: 90, + StockProfilePPT: map[string]api.TDPState{ + "quiet": {PL1SPL: 10, PL2SPPT: 15, FPPT: 15}, + "balanced": {PL1SPL: 20, PL2SPPT: 28, FPPT: 26}, + "performance": {PL1SPL: 30, PL2SPPT: 40, FPPT: 40}, + }, + } +} + +func TestDefaultLimitsAreSelfConsistent(t *testing.T) { + for _, l := range []Limits{DefaultLimits(), otherDevice()} { + if l.TDPMin >= l.TDPMaxSafe { + t.Errorf("%s: TDPMin %d not below TDPMaxSafe %d", l.Model, l.TDPMin, l.TDPMaxSafe) + } + if l.TDPMaxSafe > l.TDPMaxForced { + t.Errorf("%s: TDPMaxSafe %d above TDPMaxForced %d", l.Model, l.TDPMaxSafe, l.TDPMaxForced) + } + if l.TempMin >= l.TempMax { + t.Errorf("%s: TempMin %d not below TempMax %d", l.Model, l.TempMin, l.TempMax) + } + // A curve needs one degree per point. + if got := l.TempMax - l.TempMin; got < CurvePoints { + t.Errorf("%s: temperature range %d too narrow for %d points", l.Model, got, CurvePoints) + } + if b := l.BasicSliderMax(); b <= l.TDPMin || b > l.TDPMaxSafe { + t.Errorf("%s: BasicSliderMax %d outside (%d, %d]", l.Model, b, l.TDPMin, l.TDPMaxSafe) + } + } +} + +func TestBasicSliderMaxIsDerivedNotFixed(t *testing.T) { + // The Z13's historic hardcoded value, preserved by the derivation. + if got := DefaultLimits().BasicSliderMax(); got != 70 { + t.Errorf("Z13 BasicSliderMax = %d, want 70", got) + } + if got := otherDevice().BasicSliderMax(); got != 25 { + t.Errorf("fictional BasicSliderMax = %d, want 25", got) + } + // A device whose safe max leaves no headroom must not produce an inverted + // or below-minimum range. + tiny := Limits{TDPMin: 5, TDPMaxSafe: 6, TDPMaxForced: 10} + if got := tiny.BasicSliderMax(); got < tiny.TDPMin { + t.Errorf("tiny BasicSliderMax = %d, below TDPMin %d", got, tiny.TDPMin) + } +} + +func TestSanitizedFillsUnsetFields(t *testing.T) { + d := DefaultLimits() + got := Limits{}.Sanitized() + + if got.TDPMin != d.TDPMin || got.TDPMaxSafe != d.TDPMaxSafe || got.TDPMaxForced != d.TDPMaxForced { + t.Errorf("zero Limits did not inherit TDP defaults: %+v", got) + } + if got.TempMin != d.TempMin || got.TempMax != d.TempMax { + t.Errorf("zero Limits did not inherit temperature defaults: %+v", got) + } + if len(got.StockProfilePPT) != len(d.StockProfilePPT) { + t.Errorf("zero Limits did not inherit the stock profile table") + } + if got.Model == "" { + t.Error("zero Limits did not inherit a model name") + } +} + +func TestSanitizedKeepsProvidedValues(t *testing.T) { + o := otherDevice() + got := o.Sanitized() + + if got.TDPMaxSafe != o.TDPMaxSafe || got.TempMax != o.TempMax || got.Model != o.Model { + t.Errorf("Sanitized overwrote provided values: %+v", got) + } + // Zero is a legitimate HighTDPMinPWM — a device with no fan floor. It must + // not be "helpfully" replaced with the Z13's 204, which would clamp fan + // curves on hardware that has no such rule. + if got.HighTDPMinPWM != 0 { + t.Errorf("HighTDPMinPWM = %d, want 0 preserved (no floor on this device)", got.HighTDPMinPWM) + } +} + +func TestForceRequired(t *testing.T) { + for _, l := range []Limits{DefaultLimits(), otherDevice()} { + if l.ForceRequired(l.TDPMaxSafe) { + t.Errorf("%s: ForceRequired(%d) = true, want false at the threshold", l.Model, l.TDPMaxSafe) + } + if !l.ForceRequired(l.TDPMaxSafe + 1) { + t.Errorf("%s: ForceRequired(%d) = false, want true above it", l.Model, l.TDPMaxSafe+1) + } + } +} + +func TestFanFloorPWM(t *testing.T) { + z := DefaultLimits() + for _, tt := range []struct{ pl1, want int }{ + {pl1: 0, want: PWMMin}, + {pl1: 50, want: PWMMin}, + {pl1: z.TDPMaxSafe, want: PWMMin}, // at the threshold, unconstrained + {pl1: z.TDPMaxSafe + 1, want: z.HighTDPMinPWM}, // one over, floor applies + {pl1: z.TDPMaxForced, want: z.HighTDPMinPWM}, + } { + if got := z.FanFloorPWM(tt.pl1); got != tt.want { + t.Errorf("Z13 FanFloorPWM(%d) = %d, want %d", tt.pl1, got, tt.want) + } + } + + // A device declaring no floor must never clamp, even far above its safe max. + o := otherDevice() + if got := o.FanFloorPWM(o.TDPMaxForced); got != PWMMin { + t.Errorf("fictional FanFloorPWM(%d) = %d, want %d (device has no floor)", o.TDPMaxForced, got, PWMMin) + } +} + +func TestIsStockPPT(t *testing.T) { + for _, l := range []Limits{DefaultLimits(), otherDevice()} { + for name, stock := range l.StockProfilePPT { + if !l.IsStockPPT(stock) { + t.Errorf("%s: IsStockPPT(%s defaults %+v) = false, want true", l.Model, name, stock) + } + // The daemon mirrors APU/Platform sPPT from PL2, so a real reading + // carries values the table does not list. They must not affect it. + withMirrored := stock + withMirrored.APUSPPT = stock.PL2SPPT + withMirrored.PlatformSPPT = stock.PL2SPPT + if !l.IsStockPPT(withMirrored) { + t.Errorf("%s: IsStockPPT(%s with mirrored APU fields) = false, want true", l.Model, name) + } + } + } + + z := DefaultLimits() + for _, tdp := range []api.TDPState{ + {PL1SPL: 80, PL2SPPT: 80, FPPT: 80}, + {PL1SPL: 52, PL2SPPT: 71, FPPT: 69}, // one watt off balanced + {PL1SPL: 45, PL2SPPT: 45, FPPT: 45}, + {}, + } { + if z.IsStockPPT(tdp) { + t.Errorf("IsStockPPT(%+v) = true, want false", tdp) + } + } + + // Another device's stock values are not this device's stock values. + if z.IsStockPPT(otherDevice().StockProfilePPT["balanced"]) { + t.Error("Z13 accepted the fictional device's balanced defaults as stock") + } +} + +func TestNeedsAdvanced(t *testing.T) { + z := DefaultLimits() + + tests := []struct { + name string + profile string + tdp api.TDPState + want bool + }{ + { + // The regression this function exists for: PL1 above the basic + // slider's ceiling used to clamp to 70 and report "70 W" while the + // hardware ran at 80 W, and a save would then send 70. + name: "sustained above basic ceiling", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 80, PL2SPPT: 80, FPPT: 80}, + want: true, + }, + { + name: "exactly at the basic ceiling is representable", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 70, PL2SPPT: 70, FPPT: 70}, + want: false, + }, + { + name: "one over the basic ceiling is not", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 71, PL2SPPT: 71, FPPT: 71}, + want: true, + }, + { + // Basic mode applies one value to all three, so differing limits + // cannot be shown even when every value is inside the basic range. + // Deliberately not 52/71/70 — that is the balanced stock triple and + // is covered below as a state the user did not choose. + name: "limits differ below the ceiling", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 50, PL2SPPT: 65, FPPT: 60}, + want: true, + }, + { + name: "only PL2 differs", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 50, PL2SPPT: 60, FPPT: 50}, + want: true, + }, + { + name: "only PL3 differs", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 50, PL2SPPT: 50, FPPT: 60}, + want: true, + }, + { + // What a basic save round-trips as: the daemon defaults the blank + // PL fields to the single value, so this must stay in basic. + name: "equal triple from a basic save", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 45, PL2SPPT: 45, FPPT: 45}, + want: false, + }, + { + // APUSPPT/PlatformSPPT are daemon bookkeeping mirrored from PL2 and + // are not shown in the drawer, so they must not force advanced. + name: "mirrored APU fields are ignored", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 50, PL2SPPT: 50, FPPT: 50, APUSPPT: 70, PlatformSPPT: 70}, + want: false, + }, + { + name: "zero value is representable", + profile: ProfileCustom, + tdp: api.TDPState{}, + want: false, + }, + + // Stock profiles report the firmware's own per-profile PPT defaults, + // which differ between limits by design. Those are a starting point for + // editing, not settings the user chose, so they must never force the + // advanced view — otherwise every stock profile opens it. + { + name: "stock balanced defaults do not force advanced", + profile: "balanced", + tdp: api.TDPState{PL1SPL: 52, PL2SPPT: 71, FPPT: 70}, + want: false, + }, + { + name: "stock quiet defaults do not force advanced", + profile: "quiet", + tdp: api.TDPState{PL1SPL: 40, PL2SPPT: 55, FPPT: 55}, + want: false, + }, + { + name: "stock performance defaults do not force advanced", + profile: "performance", + tdp: api.TDPState{PL1SPL: 70, PL2SPPT: 86, FPPT: 86}, + want: false, + }, + { + // Even a reading above the ceiling stays basic on a stock profile: + // it is the firmware's value, not a saved custom one. + name: "stock profile above the ceiling still stays basic", + profile: "performance", + tdp: api.TDPState{PL1SPL: 80, PL2SPPT: 90, FPPT: 90}, + want: false, + }, + { + name: "empty profile is not custom", + profile: "", + tdp: api.TDPState{PL1SPL: 80, PL2SPPT: 80, FPPT: 80}, + want: false, + }, + + // Saving a fan curve or undervolt flips the daemon's profile to custom + // by itself, while the power limits stay at the firmware's values. The + // profile check alone would then fire on numbers the user never chose. + { + name: "custom fan curve on top of stock balanced limits", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 52, PL2SPPT: 71, FPPT: 70}, + want: false, + }, + { + name: "custom fan curve on top of stock quiet limits", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 40, PL2SPPT: 55, FPPT: 55}, + want: false, + }, + { + name: "custom fan curve on top of stock performance limits", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 70, PL2SPPT: 86, FPPT: 86}, + want: false, + }, + { + // One watt off the stock table is a deliberate edit, not firmware. + name: "one watt off stock balanced is a user choice", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 53, PL2SPPT: 71, FPPT: 70}, + want: true, + }, + { + // The case the ceiling rule exists for must survive the stock check. + name: "advanced burst limits within the basic ceiling", + profile: ProfileCustom, + tdp: api.TDPState{PL1SPL: 65, PL2SPPT: 85, FPPT: 90}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := z.NeedsAdvanced(tt.profile, tt.tdp); got != tt.want { + t.Errorf("NeedsAdvanced(%q, %+v) = %v, want %v", tt.profile, tt.tdp, got, tt.want) + } + }) + } +} + +// The ceiling that matters is the device's own, not the Z13's. +func TestNeedsAdvancedUsesTheDevicesCeiling(t *testing.T) { + o := otherDevice() // BasicSliderMax 25 + + // Well inside the Z13's basic range, but above this device's. + tdp := api.TDPState{PL1SPL: 40, PL2SPPT: 40, FPPT: 40} + if !o.NeedsAdvanced(ProfileCustom, tdp) { + t.Errorf("fictional device: NeedsAdvanced(%+v) = false, want true (over its %dW ceiling)", + tdp, o.BasicSliderMax()) + } + if DefaultLimits().NeedsAdvanced(ProfileCustom, tdp) { + t.Errorf("Z13: NeedsAdvanced(%+v) = true, want false (inside its %dW ceiling)", + tdp, DefaultLimits().BasicSliderMax()) + } + + // And this device's own stock values must not force advanced on it. + if o.NeedsAdvanced(ProfileCustom, o.StockProfilePPT["balanced"]) { + t.Error("fictional device forced advanced for its own stock balanced values") + } +} + +func TestFanCurveIsCustom(t *testing.T) { + eight := make([]api.FanCurvePoint, CurvePoints) + + tests := []struct { + name string + fc *api.FanCurveState + want bool + }{ + {name: "nil state", fc: nil, want: false}, + { + name: "custom mode with a full curve", + fc: &api.FanCurveState{Mode: FanModeCustom, Points: eight}, + want: true, + }, + { + // The regression: switching to a stock profile releases the fans to + // firmware auto, but the curve registers still read back the old + // custom points. Displaying them shows a curve the fans do not follow. + name: "auto mode still reports stale points", + fc: &api.FanCurveState{Mode: FanModeAuto, Points: eight}, + want: false, + }, + { + name: "full-speed mode", + fc: &api.FanCurveState{Mode: FanModeFullSpeed, Points: eight}, + want: false, + }, + { + name: "custom mode but a short curve is unusable", + fc: &api.FanCurveState{Mode: FanModeCustom, Points: eight[:3]}, + want: false, + }, + { + name: "custom mode with no points", + fc: &api.FanCurveState{Mode: FanModeCustom}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FanCurveIsCustom(tt.fc); got != tt.want { + t.Errorf("FanCurveIsCustom() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDefaultCurveIsValid(t *testing.T) { + for _, l := range []Limits{DefaultLimits(), otherDevice()} { + assertCurveValid(t, l, l.DefaultCurve(), PWMMin) + } +} + +// The Z13's hand-tuned shape must survive being fitted to the Z13's own range. +func TestDefaultCurveUnchangedOnTheZ13(t *testing.T) { + want := "35:0,45:25,50:50,60:80,70:120,80:170,90:220,100:255" + if got := DefaultLimits().DefaultCurve().String(); got != want { + t.Errorf("Z13 DefaultCurve() = %q, want %q", got, want) + } +} + +// A curve built for a wider temperature range must come back valid rather than +// collapsing every out-of-range point onto the maximum. +func TestEnforceCurve_NormalizesACurveFromAnotherDevice(t *testing.T) { + narrow := otherDevice() + c := DefaultLimits().DefaultCurve() // 35–100°C, outside narrow's 40–90 + narrow.EnforceCurve(&c, 0, PWMMin) + assertCurveValid(t, narrow, c, PWMMin) +} + +func TestCurveString(t *testing.T) { + c := Curve{ + {Temp: 35, PWM: 0}, {Temp: 45, PWM: 25}, {Temp: 50, PWM: 50}, {Temp: 60, PWM: 80}, + {Temp: 70, PWM: 120}, {Temp: 80, PWM: 170}, {Temp: 90, PWM: 220}, {Temp: 100, PWM: 255}, + } + want := "35:0,45:25,50:50,60:80,70:120,80:170,90:220,100:255" + if got := c.String(); got != want { + t.Errorf("String() = %q, want %q", got, want) + } +} + +// Dragging a point off the left edge must not squash the points behind it onto +// TempMin. Point 3 has three points below it, each needing its own degree, so +// the lowest it can sit is TempMin+3. +func TestEnforceCurve_TemperaturesStrictlyIncrease(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() + c[3].Temp = 20 + l.EnforceCurve(&c, 3, PWMMin) + + if want := l.TempMin + 3; c[3].Temp != want { + t.Errorf("dragged point temp = %d, want %d (leaves room for points 0-2)", c[3].Temp, want) + } + assertCurveValid(t, l, c, PWMMin) +} + +// The mirror of the above: a point dragged off the right edge must leave room +// for the points after it. +func TestEnforceCurve_LeavesRoomAboveDraggedPoint(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() + c[4].Temp = 999 + l.EnforceCurve(&c, 4, PWMMin) + + if want := l.TempMax - (CurvePoints - 1 - 4); c[4].Temp != want { + t.Errorf("dragged point temp = %d, want %d (leaves room for points 5-7)", c[4].Temp, want) + } + assertCurveValid(t, l, c, PWMMin) +} + +func TestEnforceCurve_PWMNeverDecreases(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() + c[1].PWM = 240 + l.EnforceCurve(&c, 1, PWMMin) + + if c[1].PWM != 240 { + t.Errorf("dragged point PWM = %d, want 240 preserved", c[1].PWM) + } + assertCurveValid(t, l, c, PWMMin) +} + +func TestEnforceCurve_DraggedPointWinsOverNeighbours(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() + c[5].PWM = 30 + l.EnforceCurve(&c, 5, PWMMin) + + if c[5].PWM != 30 { + t.Errorf("dragged point PWM = %d, want 30 preserved", c[5].PWM) + } + assertCurveValid(t, l, c, PWMMin) +} + +func TestEnforceCurve_ClampsOutOfRange(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() + c[7].Temp = 500 + c[7].PWM = 9000 + l.EnforceCurve(&c, 7, PWMMin) + + if c[7].Temp != l.TempMax { + t.Errorf("temp = %d, want clamped to %d", c[7].Temp, l.TempMax) + } + if c[7].PWM != PWMMax { + t.Errorf("PWM = %d, want clamped to %d", c[7].PWM, PWMMax) + } + assertCurveValid(t, l, c, PWMMin) +} + +// The floor is the whole reason the drawer cannot let a drag go low: the daemon +// rejects the entire curve if any point is under it while PL1 is high. +func TestEnforceCurve_HighTDPFloorLiftsEveryPoint(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() // 7 of its 8 points sit below the floor + l.EnforceCurve(&c, 0, l.HighTDPMinPWM) + + for i, p := range c { + if p.PWM < l.HighTDPMinPWM { + t.Errorf("point %d PWM = %d, below floor %d", i, p.PWM, l.HighTDPMinPWM) + } + } + assertCurveValid(t, l, c, l.HighTDPMinPWM) +} + +func TestEnforceCurve_DragBelowFloorIsLifted(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() + l.EnforceCurve(&c, 0, l.HighTDPMinPWM) // start from a floored curve + + c[2].PWM = 10 // user drags a point to the bottom + l.EnforceCurve(&c, 2, l.HighTDPMinPWM) + + if c[2].PWM < l.HighTDPMinPWM { + t.Errorf("dragged point PWM = %d, want lifted to at least %d", c[2].PWM, l.HighTDPMinPWM) + } + assertCurveValid(t, l, c, l.HighTDPMinPWM) +} + +func TestEnforceCurve_Idempotent(t *testing.T) { + l := DefaultLimits() + for _, floor := range []int{PWMMin, l.HighTDPMinPWM} { + c := l.DefaultCurve() + c[4].PWM = 12 + c[4].Temp = 33 + l.EnforceCurve(&c, 4, floor) + once := c + l.EnforceCurve(&c, 4, floor) + if c != once { + t.Errorf("floor %d: second pass changed the curve: %v then %v", floor, once, c) + } + } +} + +func TestEnforceCurve_OutOfRangeIndexIsIgnored(t *testing.T) { + l := DefaultLimits() + c := l.DefaultCurve() + before := c + l.EnforceCurve(&c, -1, PWMMin) + l.EnforceCurve(&c, CurvePoints, PWMMin) + if c != before { + t.Error("out-of-range index modified the curve") + } +} + +// Every index dragged to every extreme, on both devices, at every floor. This is +// the sweep that caught the original squash bug, and it is what proves the +// constraint logic is not tuned to the Z13's temperature range. +func TestEnforceCurve_AllIndicesAtBothExtremesOnEveryDevice(t *testing.T) { + for _, l := range []Limits{DefaultLimits(), otherDevice()} { + for _, floor := range []int{PWMMin, l.HighTDPMinPWM} { + for idx := 0; idx < CurvePoints; idx++ { + for _, temp := range []int{-100, 0, 20, 500} { + c := l.DefaultCurve() + c[idx].Temp = temp + l.EnforceCurve(&c, idx, floor) + assertCurveValid(t, l, c, floor) + } + } + } + } +} + +// assertCurveValid checks every invariant the daemon and firmware require. +func assertCurveValid(t *testing.T, l Limits, c Curve, minPWM int) { + t.Helper() + for i, p := range c { + if p.Temp < l.TempMin || p.Temp > l.TempMax { + t.Errorf("%s: point %d temp = %d, outside [%d,%d]", l.Model, i, p.Temp, l.TempMin, l.TempMax) + } + if p.PWM < minPWM || p.PWM > PWMMax { + t.Errorf("%s: point %d PWM = %d, outside [%d,%d]", l.Model, i, p.PWM, minPWM, PWMMax) + } + if i > 0 { + if c[i].Temp <= c[i-1].Temp { + t.Errorf("%s: temps not strictly increasing at %d: %d then %d", l.Model, i, c[i-1].Temp, c[i].Temp) + } + if c[i].PWM < c[i-1].PWM { + t.Errorf("%s: PWM decreased at %d: %d then %d", l.Model, i, c[i-1].PWM, c[i].PWM) + } + } + } +} + +// assertLimitsUsable states what the rest of the drawer assumes about a Limits +// value: the TDP bounds are ordered, the temperature axis is wide enough for a +// curve, and the fan floor is a real PWM value. Anything violating these produces +// either a curve the daemon rejects or NaN coordinates in the editor. +func assertLimitsUsable(t *testing.T, l Limits) { + t.Helper() + if l.TDPMin >= l.TDPMaxSafe { + t.Errorf("TDPMin %d not below TDPMaxSafe %d", l.TDPMin, l.TDPMaxSafe) + } + if l.TDPMaxSafe > l.TDPMaxForced { + t.Errorf("TDPMaxSafe %d above TDPMaxForced %d", l.TDPMaxSafe, l.TDPMaxForced) + } + if got := l.TempMax - l.TempMin; got < CurvePoints-1 { + t.Errorf("temperature range %d too narrow for %d points", got, CurvePoints) + } + if l.HighTDPMinPWM < PWMMin || l.HighTDPMinPWM > PWMMax { + t.Errorf("HighTDPMinPWM %d outside [%d,%d]", l.HighTDPMinPWM, PWMMin, PWMMax) + } + if l.BasicSliderMax() > l.TDPMaxForced { + t.Errorf("BasicSliderMax %d above TDPMaxForced %d", l.BasicSliderMax(), l.TDPMaxForced) + } + if len(l.StockProfilePPT) == 0 { + t.Error("StockProfilePPT is empty") + } +} + +// TestSanitizedRepairsInconsistentLimits covers the values a daemon-served +// device description could carry that a zero check cannot catch. This is the path +// multi-device support opens: today Limits is a compiled-in constant, tomorrow it +// arrives over a socket from a daemon of unknown version. +func TestSanitizedRepairsInconsistentLimits(t *testing.T) { + d := DefaultLimits() + + tests := []struct { + name string + in Limits + }{ + {"zero value", Limits{}}, + {"temp axis inverted", Limits{TempMin: 90, TempMax: 40}}, + {"temp axis collapsed", Limits{TempMin: 50, TempMax: 50}}, + {"temp axis too narrow for the curve", Limits{TempMin: 50, TempMax: 55}}, + {"temp axis exactly one degree short", Limits{TempMin: 40, TempMax: 40 + CurvePoints - 2}}, + {"safe max below the minimum", Limits{TDPMin: 60, TDPMaxSafe: 30, TDPMaxForced: 90}}, + {"safe max above the forced max", Limits{TDPMin: 5, TDPMaxSafe: 95, TDPMaxForced: 90}}, + {"min equals safe max", Limits{TDPMin: 50, TDPMaxSafe: 50, TDPMaxForced: 90}}, + {"fan floor above the hwmon maximum", Limits{HighTDPMinPWM: 999}}, + {"fan floor negative", Limits{HighTDPMinPWM: -5}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.in.Sanitized() + assertLimitsUsable(t, got) + // Whatever it repaired, the placeholder curve must still be valid — the + // editor draws it before the daemon reports anything. + assertCurveValid(t, got, got.DefaultCurve(), PWMMin) + }) + } + + // A device with no fan floor keeps that: zero is legitimate there, and the + // repair above must not mistake it for a missing value. + t.Run("zero fan floor is preserved", func(t *testing.T) { + if got := (Limits{HighTDPMinPWM: 0}).Sanitized(); got.HighTDPMinPWM != 0 { + t.Errorf("HighTDPMinPWM = %d, want 0 preserved", got.HighTDPMinPWM) + } + }) + + // A consistent description must pass through untouched, so the repair cannot + // quietly overwrite a legitimate device with the Z13's numbers. + t.Run("consistent limits are untouched", func(t *testing.T) { + for _, l := range []Limits{d, otherDevice()} { + got := l.Sanitized() + if got.TDPMin != l.TDPMin || got.TDPMaxSafe != l.TDPMaxSafe || + got.TDPMaxForced != l.TDPMaxForced || + got.TempMin != l.TempMin || got.TempMax != l.TempMax || + got.HighTDPMinPWM != l.HighTDPMinPWM { + t.Errorf("%s: Sanitized altered a consistent value: %+v -> %+v", l.Model, l, got) + } + } + }) +} + +// TestSanitizedIsIdempotent — a repaired value must not repair further, or the +// result would depend on how many times it had been through. +func TestSanitizedIsIdempotent(t *testing.T) { + for _, in := range []Limits{{}, {TempMin: 50, TempMax: 50}, {TDPMin: 60, TDPMaxSafe: 30}} { + once := in.Sanitized() + twice := once.Sanitized() + if once.TDPMin != twice.TDPMin || once.TDPMaxSafe != twice.TDPMaxSafe || + once.TDPMaxForced != twice.TDPMaxForced || + once.TempMin != twice.TempMin || once.TempMax != twice.TempMax || + once.HighTDPMinPWM != twice.HighTDPMinPWM { + t.Errorf("not idempotent: %+v -> %+v -> %+v", in, once, twice) + } + } +} + +// TestEnforceCurveSurvivesASanitizedNarrowAxis is the failure this repair +// prevents, stated end to end: take the narrowest axis Sanitized will accept, +// push a point to each extreme, and require a valid curve every time. +func TestEnforceCurveOnNarrowestAcceptedAxis(t *testing.T) { + l := Limits{TempMin: 40, TempMax: 40 + CurvePoints - 1}.Sanitized() + assertLimitsUsable(t, l) + + for idx := 0; idx < CurvePoints; idx++ { + for _, temp := range []int{-100, 0, l.TempMin, l.TempMax, 10000} { + c := l.DefaultCurve() + c[idx].Temp = temp + l.EnforceCurve(&c, idx, PWMMin) + assertCurveValid(t, l, c, PWMMin) + } + } +} diff --git a/internal/startup/args.go b/internal/startup/args.go new file mode 100644 index 0000000..b6d8a99 --- /dev/null +++ b/internal/startup/args.go @@ -0,0 +1,74 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package startup holds the process-startup logic that runs before any GTK code: +// command-line scanning and log filtering. +// +// It is separate from main and internal/gui so it can be unit tested — both of +// those need CGO and GTK4 headers to compile at all. +package startup + +// Action is an immediate, exit-after-printing request from the command line. +type Action string + +const ( + // ActionNone means carry on and start the GUI. + ActionNone Action = "" + + // ActionVersion prints the version and exits. + ActionVersion Action = "version" + // ActionPrintTheme prints the default theme.toml and exits. + ActionPrintTheme Action = "print-theme" + // ActionListThemes prints the built-in theme IDs and exits. + ActionListThemes Action = "list-themes" +) + +// Args is the result of scanning the command line. +type Args struct { + Debug bool // -d / --debug: log everything, including GTK internals + Action Action // an immediate action to perform instead of starting + GTKArgs []string // argv to hand to GApplication, including argv[0] +} + +// ParseArgs scans argv for the flags z13gui handles itself and passes everything +// else through to GTK. +// +// The flag package is deliberately not used: app.Run() forwards the remaining +// arguments to GLib's option parser, which errors on anything it does not +// recognise, so our flags have to be removed from the slice rather than merely +// read. argv[0] is always preserved as the first passthrough element because +// GApplication expects it. +// +// When several actions are given the first one wins, matching the +// print-and-exit-immediately behaviour a user gets from a single flag. Flags are +// still scanned to the end so that -d anywhere on the line is honoured. +func ParseArgs(argv []string) Args { + out := Args{Action: ActionNone} + if len(argv) == 0 { + out.GTKArgs = []string{""} + return out + } + out.GTKArgs = []string{argv[0]} + + for _, arg := range argv[1:] { + switch arg { + case "--debug", "-d": + out.Debug = true + case "--version": + if out.Action == ActionNone { + out.Action = ActionVersion + } + case "--print-theme": + if out.Action == ActionNone { + out.Action = ActionPrintTheme + } + case "--list-themes": + if out.Action == ActionNone { + out.Action = ActionListThemes + } + default: + out.GTKArgs = append(out.GTKArgs, arg) + } + } + return out +} diff --git a/internal/startup/logfilter.go b/internal/startup/logfilter.go new file mode 100644 index 0000000..61226cb --- /dev/null +++ b/internal/startup/logfilter.go @@ -0,0 +1,88 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package startup + +import ( + "context" + "log/slog" +) + +// GLibDomainKey is the attribute gotk4 attaches to every message it forwards from +// GLib/GTK. It is how app logs are told apart from toolkit noise. +const GLibDomainKey = "glib_domain" + +// filterHandler applies separate level thresholds to app logs and to GTK/GLib +// logs. gotk4's glib.init() routes every GLib/GTK message through slog.Default() +// with a glib_domain attribute, so without this the drawer's own Info lines are +// buried under toolkit chatter — and turning the level up to hide that chatter +// would hide the drawer's messages too. +type filterHandler struct { + inner slog.Handler + appLevel slog.Level + gtkLevel slog.Level + + // gtkFromAttrs records that glib_domain arrived through WithAttrs rather than + // on the record. A handler that only inspected the record would misclassify + // every message from a logger derived with slog.With(glib_domain, …) — gotk4 + // currently passes it per-record, but the slog.Handler contract requires + // WithAttrs values to be treated as though they were on the record. + gtkFromAttrs bool +} + +// NewFilterHandler wraps inner with split-level filtering. appLevel is the +// threshold for application messages, gtkLevel for GTK/GLib ones. +func NewFilterHandler(inner slog.Handler, appLevel, gtkLevel slog.Level) slog.Handler { + return &filterHandler{inner: inner, appLevel: appLevel, gtkLevel: gtkLevel} +} + +// Enabled passes anything either threshold would admit: the source of a message +// is not known until Handle can inspect its attributes. +func (h *filterHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.appLevel || level >= h.gtkLevel +} + +func (h *filterHandler) Handle(ctx context.Context, r slog.Record) error { + isGTK := h.gtkFromAttrs + if !isGTK { + r.Attrs(func(a slog.Attr) bool { + if a.Key == GLibDomainKey { + isGTK = true + return false + } + return true + }) + } + if isGTK && r.Level < h.gtkLevel { + return nil + } + if !isGTK && r.Level < h.appLevel { + return nil + } + return h.inner.Handle(ctx, r) +} + +func (h *filterHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + gtk := h.gtkFromAttrs + for _, a := range attrs { + if a.Key == GLibDomainKey { + gtk = true + break + } + } + return &filterHandler{ + inner: h.inner.WithAttrs(attrs), + appLevel: h.appLevel, + gtkLevel: h.gtkLevel, + gtkFromAttrs: gtk, + } +} + +func (h *filterHandler) WithGroup(name string) slog.Handler { + return &filterHandler{ + inner: h.inner.WithGroup(name), + appLevel: h.appLevel, + gtkLevel: h.gtkLevel, + gtkFromAttrs: h.gtkFromAttrs, + } +} diff --git a/internal/startup/startup_test.go b/internal/startup/startup_test.go new file mode 100644 index 0000000..5bdc2ed --- /dev/null +++ b/internal/startup/startup_test.go @@ -0,0 +1,212 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package startup + +import ( + "bytes" + "context" + "log/slog" + "strings" + "testing" +) + +func TestParseArgsPassesUnknownFlagsThrough(t *testing.T) { + got := ParseArgs([]string{"z13gui", "--gtk-something", "extra"}) + + if got.Debug { + t.Error("Debug = true, want false") + } + if got.Action != ActionNone { + t.Errorf("Action = %q, want none", got.Action) + } + want := []string{"z13gui", "--gtk-something", "extra"} + assertArgs(t, got.GTKArgs, want) +} + +// Our own flags must be removed, not merely read: app.Run() hands the remainder to +// GLib's option parser, which errors on anything it does not recognise. +func TestParseArgsConsumesOurFlags(t *testing.T) { + for _, flag := range []string{"-d", "--debug", "--version", "--print-theme", "--list-themes"} { + got := ParseArgs([]string{"z13gui", flag}) + assertArgs(t, got.GTKArgs, []string{"z13gui"}) + } +} + +func TestParseArgsDebugFlag(t *testing.T) { + for _, flag := range []string{"-d", "--debug"} { + if got := ParseArgs([]string{"z13gui", flag}); !got.Debug { + t.Errorf("ParseArgs(%q) Debug = false, want true", flag) + } + } + // -d must be honoured wherever it appears, including after another flag. + got := ParseArgs([]string{"z13gui", "--print-theme", "-d"}) + if !got.Debug { + t.Error("Debug = false, want true when -d follows an action") + } + if got.Action != ActionPrintTheme { + t.Errorf("Action = %q, want print-theme", got.Action) + } +} + +func TestParseArgsActions(t *testing.T) { + tests := []struct { + flag string + want Action + }{ + {flag: "--version", want: ActionVersion}, + {flag: "--print-theme", want: ActionPrintTheme}, + {flag: "--list-themes", want: ActionListThemes}, + } + for _, tt := range tests { + if got := ParseArgs([]string{"z13gui", tt.flag}); got.Action != tt.want { + t.Errorf("ParseArgs(%q) Action = %q, want %q", tt.flag, got.Action, tt.want) + } + } +} + +// Several actions on one line: the first wins, matching what a user sees from a +// single flag — print and exit. +func TestParseArgsFirstActionWins(t *testing.T) { + got := ParseArgs([]string{"z13gui", "--list-themes", "--version"}) + if got.Action != ActionListThemes { + t.Errorf("Action = %q, want list-themes (the first given)", got.Action) + } +} + +// GApplication expects argv[0]; dropping it would break option parsing in ways +// that only show up at runtime. +func TestParseArgsAlwaysKeepsArgv0(t *testing.T) { + got := ParseArgs([]string{"/usr/local/bin/z13gui", "-d"}) + assertArgs(t, got.GTKArgs, []string{"/usr/local/bin/z13gui"}) +} + +func TestParseArgsHandlesEmptyArgv(t *testing.T) { + got := ParseArgs(nil) + if len(got.GTKArgs) != 1 { + t.Errorf("GTKArgs = %v, want a single placeholder element", got.GTKArgs) + } + if got.Debug || got.Action != ActionNone { + t.Errorf("unexpected result for empty argv: %+v", got) + } + if got := ParseArgs([]string{}); len(got.GTKArgs) != 1 { + t.Errorf("GTKArgs = %v, want a single placeholder element", got.GTKArgs) + } +} + +func assertArgs(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("GTKArgs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("GTKArgs = %v, want %v", got, want) + } + } +} + +// newTestLogger returns a logger writing to buf through the filter handler. +func newTestLogger(buf *bytes.Buffer, appLevel, gtkLevel slog.Level) *slog.Logger { + inner := slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + return slog.New(NewFilterHandler(inner, appLevel, gtkLevel)) +} + +func TestFilterHandlerSplitsAppAndGTKThresholds(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf, slog.LevelInfo, slog.LevelError) + + log.Info("app info") // app >= Info: kept + log.Debug("app debug") // below app level: dropped + log.Info("gtk info", GLibDomainKey, "Gtk") // GTK below Error: dropped + log.Error("gtk error", GLibDomainKey, "Gtk") // GTK >= Error: kept + log.Warn("gtk warn", GLibDomainKey, "Gdk") // GTK below Error: dropped + + out := buf.String() + for _, want := range []string{"app info", "gtk error"} { + if !strings.Contains(out, want) { + t.Errorf("output is missing %q:\n%s", want, out) + } + } + for _, unwanted := range []string{"app debug", "gtk info", "gtk warn"} { + if strings.Contains(out, unwanted) { + t.Errorf("output should not contain %q:\n%s", unwanted, out) + } + } +} + +// The -d case: both thresholds drop to Debug, so everything is shown. +func TestFilterHandlerDebugModeShowsEverything(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf, slog.LevelDebug, slog.LevelDebug) + + log.Debug("app debug") + log.Debug("gtk debug", GLibDomainKey, "Gtk") + + out := buf.String() + if !strings.Contains(out, "app debug") || !strings.Contains(out, "gtk debug") { + t.Errorf("debug mode dropped messages:\n%s", out) + } +} + +// The bug this fix addresses: a logger derived with slog.With(glib_domain, …) +// carries the attribute outside the record, so a handler that only inspected the +// record would classify GTK noise as application output and let it through. +func TestFilterHandlerClassifiesGTKAttrsFromWithAttrs(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf, slog.LevelInfo, slog.LevelError).With(GLibDomainKey, "Gtk") + + log.Info("gtk noise via With") + if got := buf.String(); strings.Contains(got, "gtk noise via With") { + t.Errorf("GTK message from a derived logger was not filtered:\n%s", got) + } + + log.Error("gtk failure via With") + if got := buf.String(); !strings.Contains(got, "gtk failure via With") { + t.Errorf("GTK error from a derived logger was dropped:\n%s", got) + } +} + +// WithGroup must not lose the classification established by WithAttrs. +func TestFilterHandlerWithGroupPreservesClassification(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf, slog.LevelInfo, slog.LevelError). + With(GLibDomainKey, "Gtk"). + WithGroup("g") + + log.Info("still gtk") + if got := buf.String(); strings.Contains(got, "still gtk") { + t.Errorf("classification lost across WithGroup:\n%s", got) + } +} + +// An app logger that adds unrelated attributes must stay classified as app. +func TestFilterHandlerWithAttrsDoesNotMisclassifyAppLogs(t *testing.T) { + var buf bytes.Buffer + log := newTestLogger(&buf, slog.LevelInfo, slog.LevelError).With("component", "drawer") + + log.Info("app message with attrs") + if got := buf.String(); !strings.Contains(got, "app message with attrs") { + t.Errorf("app message with attrs was dropped:\n%s", got) + } +} + +// Enabled cannot know a record's source yet, so it must admit anything either +// threshold would accept — otherwise GTK errors would be discarded before Handle +// ever sees them. +func TestFilterHandlerEnabledAdmitsEitherThreshold(t *testing.T) { + h := NewFilterHandler(slog.NewTextHandler(&bytes.Buffer{}, nil), slog.LevelWarn, slog.LevelDebug) + ctx := context.Background() + + if !h.Enabled(ctx, slog.LevelDebug) { + t.Error("Enabled(Debug) = false, want true — the GTK threshold accepts it") + } + if !h.Enabled(ctx, slog.LevelError) { + t.Error("Enabled(Error) = false, want true") + } + + strict := NewFilterHandler(slog.NewTextHandler(&bytes.Buffer{}, nil), slog.LevelError, slog.LevelError) + if strict.Enabled(ctx, slog.LevelInfo) { + t.Error("Enabled(Info) = true, want false when both thresholds are Error") + } +} diff --git a/internal/theme/config.go b/internal/theme/config.go index b23aac2..97bec87 100644 --- a/internal/theme/config.go +++ b/internal/theme/config.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme import ( diff --git a/internal/theme/config_test.go b/internal/theme/config_test.go index d9fba58..d734c62 100644 --- a/internal/theme/config_test.go +++ b/internal/theme/config_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme import ( diff --git a/internal/theme/css.go b/internal/theme/css.go index ee4c1b2..175a4dc 100644 --- a/internal/theme/css.go +++ b/internal/theme/css.go @@ -1,7 +1,12 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme import ( "fmt" + "regexp" + "sort" "strings" ) @@ -17,12 +22,56 @@ func BuildThemeCSS(c Colors, templateCSS string) string { "@define-color z13-surface-alt %s;\n"+ "@define-color z13-text %s;\n"+ "@define-color z13-text-dim %s;\n"+ - "@define-color z13-border %s;\n", - c.Accent, c.Background, c.Surface, c.SurfaceAlt, c.Text, c.TextDim, c.Border, + "@define-color z13-border %s;\n"+ + "@define-color z13-error %s;\n", + c.Accent, c.Background, c.Surface, c.SurfaceAlt, c.Text, c.TextDim, c.Border, c.Error, ) return defs + "\n" + StripDefineColors(templateCSS) } +// definePattern matches an "@define-color z13-foo …;" declaration, capturing the +// token name. referencePattern matches a "@z13-foo" use. +var ( + definePattern = regexp.MustCompile(`@define-color\s+(z13-[a-z0-9-]+)`) + referencePattern = regexp.MustCompile(`@(z13-[a-z0-9-]+)`) +) + +// UndefinedColorTokens returns, sorted, the @z13-* tokens css references without +// also defining — the tokens that make a stylesheet fail to stand on its own. +// +// This distinction is easy to miss because the two ways of supplying a theme are +// not symmetrical. A theme.toml is substituted into a copy of the embedded +// template, which is prefixed with a full set of @define-color lines, so a missing +// definition there is invisible. A theme.css is loaded **verbatim**: anything it +// references and does not define is simply undefined, and every rule using it is +// dropped. The embedded template shipped for months referencing @z13-error without +// defining it, so anyone who followed its own instructions and copied it to +// theme.css lost the error bar's and the TDP warning's colours. +// +// References inside comments are counted, deliberately: the template documents its +// tokens in a comment block, and a token advertised there but never defined is the +// same broken promise to whoever copies the file. +func UndefinedColorTokens(css string) []string { + defined := make(map[string]bool) + for _, m := range definePattern.FindAllStringSubmatch(css, -1) { + defined[m[1]] = true + } + seen := make(map[string]bool) + var missing []string + for _, m := range referencePattern.FindAllStringSubmatch(css, -1) { + tok := m[1] + // A define's own name matches referencePattern too; skip those positions by + // checking definition membership first. + if defined[tok] || seen[tok] { + continue + } + seen[tok] = true + missing = append(missing, tok) + } + sort.Strings(missing) + return missing +} + // StripDefineColors removes all @define-color lines from a CSS string. func StripDefineColors(css string) string { var b strings.Builder diff --git a/internal/theme/css_test.go b/internal/theme/css_test.go index c82c6ec..d98f31e 100644 --- a/internal/theme/css_test.go +++ b/internal/theme/css_test.go @@ -1,6 +1,11 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme import ( + "os" + "regexp" "strings" "testing" ) @@ -12,11 +17,12 @@ func TestBuildThemeCSS_ContainsDefineColors(t *testing.T) { expected := []string{ "@define-color z13-accent", "@define-color z13-bg", - "@define-color z13-surface ", // trailing space to distinguish from z13-surface-alt + "@define-color z13-surface ", // trailing space to distinguish from z13-surface-alt "@define-color z13-surface-alt", - "@define-color z13-text ", // trailing space to distinguish from z13-text-dim + "@define-color z13-text ", // trailing space to distinguish from z13-text-dim "@define-color z13-text-dim", "@define-color z13-border", + "@define-color z13-error", } for _, exp := range expected { if !strings.Contains(css, exp) { @@ -34,9 +40,10 @@ func TestBuildThemeCSS_ContainsColorValues(t *testing.T) { Text: "#eeeeee", TextDim: "#999999", Border: "#555555", + Error: "#ff8888", } css := BuildThemeCSS(c, "") - for _, hex := range []string{"#ff0000", "#111111", "#222222", "#333333", "#eeeeee", "#999999", "#555555"} { + for _, hex := range []string{"#ff0000", "#111111", "#222222", "#333333", "#eeeeee", "#999999", "#555555", "#ff8888"} { if !strings.Contains(css, hex) { t.Errorf("output missing color value %s", hex) } @@ -111,3 +118,109 @@ func TestBuildThemeCSS_EmptyTemplate(t *testing.T) { t.Error("even with empty template, @define-color lines should be present") } } + +func TestUndefinedColorTokens(t *testing.T) { + tests := []struct { + name string + css string + want []string + }{ + { + name: "self-contained", + css: "@define-color z13-accent #cc0000;\n.a { color: @z13-accent; }\n", + want: nil, + }, + { + // The shipped bug: four rules used @z13-error and nothing defined it. + name: "referenced but never defined", + css: "@define-color z13-accent #cc0000;\n.a { color: @z13-error; }\n", + want: []string{"z13-error"}, + }, + { + name: "several missing, sorted and deduplicated", + css: ".a { color: @z13-text; border-color: @z13-border; }\n.b { color: @z13-text; }\n", + want: []string{"z13-border", "z13-text"}, + }, + { + // A token advertised in a comment but never defined is the same broken + // promise to anyone copying the file, so it counts. + name: "advertised in a comment only", + css: "/* Available: @z13-radius — corner radius */\n.a { color: red; }\n", + want: []string{"z13-radius"}, + }, + { + name: "a define does not count as an undefined reference", + css: "@define-color z13-bg #1a1a1a;\n", + want: nil, + }, + { + name: "no tokens at all", + css: ".a { color: red; }\n", + want: nil, + }, + { + name: "empty", + css: "", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := UndefinedColorTokens(tt.css) + if len(got) != len(tt.want) { + t.Fatalf("UndefinedColorTokens = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("UndefinedColorTokens = %v, want %v", got, tt.want) + } + } + }) + } +} + +// TestEmbeddedTemplateIsSelfContained is the regression guard for the bug above. +// +// The template lives in internal/gui, which needs CGO and GTK4 headers and so has +// no tests of its own — hence reaching across for the file rather than importing +// it. The assertion is worth the awkward path: the template doubles as the +// documented starting point for a hand-written theme.css, which is loaded verbatim, +// so a token it references without defining silently drops every rule that uses it. +func TestEmbeddedTemplateIsSelfContained(t *testing.T) { + const path = "../gui/theme-default.css" + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cannot read the embedded theme template: %v", err) + } + if missing := UndefinedColorTokens(string(data)); len(missing) > 0 { + t.Errorf("%s references colour tokens it does not define: %v\n"+ + "It is documented as a starting point for theme.css, which is loaded "+ + "verbatim, so every rule using these would be dropped.", path, missing) + } +} + +// The template must also define exactly the tokens Colors carries, so that a +// verbatim copy and a BuildThemeCSS-substituted copy style the same things. A +// token in Colors but not the template means the standalone path is missing a +// colour; the reverse means BuildThemeCSS cannot override one. +func TestEmbeddedTemplateDefinesEveryColorToken(t *testing.T) { + data, err := os.ReadFile("../gui/theme-default.css") + if err != nil { + t.Fatalf("cannot read the embedded theme template: %v", err) + } + css := string(data) + + // The tokens BuildThemeCSS emits, taken from its own output so the two cannot + // drift apart. + for _, tok := range UndefinedColorTokens(StripDefineColors(BuildThemeCSS(DefaultColors, ""))) { + t.Errorf("BuildThemeCSS emits a reference to %s that it does not define", tok) + } + generated := BuildThemeCSS(DefaultColors, "") + for _, m := range definePattern.FindAllStringSubmatch(generated, -1) { + if !strings.Contains(css, "@define-color "+m[1]) && + !regexp.MustCompile(`@define-color\s+`+regexp.QuoteMeta(m[1])).MatchString(css) { + t.Errorf("BuildThemeCSS defines %s but the template does not, so a "+ + "verbatim theme.css copy would leave it undefined", m[1]) + } + } +} diff --git a/internal/theme/doc.go b/internal/theme/doc.go index 46e1cd9..ad5b6fd 100644 --- a/internal/theme/doc.go +++ b/internal/theme/doc.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + // Package theme provides color theme definitions, configuration persistence, // and CSS generation for the z13gui overlay drawer. All types and functions // in this package are pure Go with no GTK or cgo dependencies, making them diff --git a/internal/theme/parse.go b/internal/theme/parse.go index e33c368..8df7ad7 100644 --- a/internal/theme/parse.go +++ b/internal/theme/parse.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme import "strings" @@ -33,16 +36,12 @@ func ParseThemeTOMLFull(data []byte) (Colors, []Accent) { continue } - // Strip inline comments. - if i := strings.Index(line, " #"); i >= 0 { - line = strings.TrimSpace(line[:i]) - } k, v, ok := strings.Cut(line, "=") if !ok { continue } k = strings.TrimSpace(k) - v = strings.Trim(strings.TrimSpace(v), `"'`) + v = parseValue(v) if !IsHexColor(v) { continue } @@ -71,11 +70,38 @@ func ParseThemeTOMLFull(data []byte) (Colors, []Accent) { c.TextDim = v case "border": c.Border = v + case "error": + c.Error = v } } return c, accents } +// parseValue extracts the value from the right-hand side of a `key = value` line, +// handling both quoted and bare forms and discarding any trailing comment. +// +// The comment stripping has to happen after the value is isolated, not before. +// Scanning the whole line for " #" first meant the bare form documented here — +// `accent = #ff0000` — had its own value taken for a comment, leaving an empty +// string that silently fell back to the default. A hex colour begins with the +// same character a comment does, which is what made a line-level scan wrong. +func parseValue(rhs string) string { + v := strings.TrimSpace(rhs) + if v != "" && (v[0] == '"' || v[0] == '\'') { + quote := v[0] + if end := strings.IndexByte(v[1:], quote); end >= 0 { + return v[1 : 1+end] + } + return strings.Trim(v, `"'`) // unterminated quote; salvage what is there + } + // Bare value: everything up to the first space, which drops a trailing comment + // without mistaking the value's own leading '#' for one. + if i := strings.IndexAny(v, " \t"); i >= 0 { + v = v[:i] + } + return v +} + // titleCase uppercases the first byte of s. Only correct for ASCII strings, // which is fine for accent IDs like "blue" or "sapphire". func titleCase(s string) string { diff --git a/internal/theme/parse_test.go b/internal/theme/parse_test.go index 91b8830..179d220 100644 --- a/internal/theme/parse_test.go +++ b/internal/theme/parse_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme import "testing" @@ -46,6 +49,7 @@ surface_alt = "#333333" text = "#eeeeee" text_dim = "#999999" border = "#555555" +error = "#ff8888" `) c := ParseThemeTOML(data) if c.Accent != "#ff0000" { @@ -69,6 +73,31 @@ border = "#555555" if c.Border != "#555555" { t.Errorf("Border = %q, want #555555", c.Border) } + if c.Error != "#ff8888" { + t.Errorf("Error = %q, want #ff8888", c.Error) + } +} + +// A theme.toml written before the error color existed must keep working: the +// parser starts from DefaultColors, so the missing key inherits the default +// rather than producing an empty @define-color that would break the stylesheet. +func TestParseThemeTOML_PreErrorKeyThemeStillWorks(t *testing.T) { + data := []byte(` +accent = "#ff0000" +background = "#111111" +surface = "#222222" +surface_alt = "#333333" +text = "#eeeeee" +text_dim = "#999999" +border = "#555555" +`) + c := ParseThemeTOML(data) + if c.Error != DefaultColors.Error { + t.Errorf("Error = %q, want default %q", c.Error, DefaultColors.Error) + } + if !IsHexColor(c.Error) { + t.Errorf("Error = %q is not a valid hex color; generated CSS would be malformed", c.Error) + } } func TestParseThemeTOML_MissingKeysKeepDefaults(t *testing.T) { @@ -315,3 +344,65 @@ blue = "#0000ff" t.Errorf("Background = %q, want #111111", c.Background) } } + +// TestParseThemeTOMLValueForms covers both spellings the doc comment promises, +// with and without a trailing comment. +// +// The bare form never worked: comments were stripped by scanning the whole line +// for " #" before the value was isolated, and a hex colour starts with the same +// character a comment does — so `accent = #ff0000` had its own value taken for a +// comment and fell back to the default without a word. +func TestParseThemeTOMLValueForms(t *testing.T) { + tests := []struct { + name string + in string + }{ + {"quoted", `accent = "#ff0000"`}, + {"bare", `accent = #ff0000`}, + {"quoted with comment", `accent = "#ff0000" # brand red`}, + {"bare with comment", `accent = #ff0000 # brand red`}, + {"no spaces around equals", `accent="#ff0000"`}, + {"single quotes", `accent = '#ff0000'`}, + {"extra whitespace", ` accent = "#ff0000" `}, + {"tab before comment", "accent = #ff0000\t# red"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ParseThemeTOML([]byte(tt.in)).Accent; got != "#ff0000" { + t.Errorf("ParseThemeTOML(%q).Accent = %q, want %q", tt.in, got, "#ff0000") + } + }) + } +} + +// A comment-only or malformed line must leave the default alone rather than +// setting something odd. +func TestParseThemeTOMLIgnoresJunk(t *testing.T) { + for _, in := range []string{ + `# accent = "#ff0000"`, + `accent =`, + `accent = ""`, + `accent = not-a-colour`, + `accent = "#12345"`, + `accent`, + } { + if got := ParseThemeTOML([]byte(in)).Accent; got != DefaultColors.Accent { + t.Errorf("ParseThemeTOML(%q).Accent = %q, want the default %q", + in, got, DefaultColors.Accent) + } + } +} + +// Bare values work in the [accents] section too, since it uses the same parser. +func TestParseAccentsAcceptsBareValues(t *testing.T) { + _, accents := ParseThemeTOMLFull([]byte("[accents]\nblue = #89b4fa\nred = \"#f38ba8\" # muted\n")) + if len(accents) != 2 { + t.Fatalf("got %d accents, want 2: %+v", len(accents), accents) + } + if accents[0].ID != "blue" || accents[0].Hex != "#89b4fa" { + t.Errorf("accents[0] = %+v, want blue/#89b4fa", accents[0]) + } + if accents[1].ID != "red" || accents[1].Hex != "#f38ba8" { + t.Errorf("accents[1] = %+v, want red/#f38ba8", accents[1]) + } +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go index b7cb4b5..a63c876 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -1,6 +1,9 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme -// Colors holds the 7 named color values that drive the entire GUI theme. +// Colors holds the 8 named color values that drive the entire GUI theme. // Each field is a CSS hex color string like "#cc0000". type Colors struct { Accent string // @z13-accent — active buttons, slider fill, checked states @@ -10,10 +13,13 @@ type Colors struct { Text string // @z13-text — primary text TextDim string // @z13-text-dim — section labels, secondary text Border string // @z13-border — window border, separators + Error string // @z13-error — error bar text/border, high-TDP warning } // DefaultColors is the ROG Dark default color set, used as the fallback when -// no theme is selected or a user theme.toml omits a color key. +// no theme is selected or a user theme.toml omits a color key. Parsing starts +// from this value, so a theme.toml written before a color was introduced keeps +// working — it simply inherits the default for the key it does not mention. var DefaultColors = Colors{ Accent: "#cc0000", Background: "#1a1a1a", @@ -22,6 +28,7 @@ var DefaultColors = Colors{ Text: "#e0e0e0", TextDim: "#888888", Border: "#444444", + Error: "#ff4444", } // Accent is an alternate accent color for a theme. Catppuccin themes, for @@ -35,8 +42,8 @@ type Accent struct { // Builtin pairs a theme ID and display name with its color definition // and optional accent color variants. type Builtin struct { - ID string // config key, e.g. "catppuccin-mocha" - Name string // display name shown in the theme picker + ID string // config key, e.g. "catppuccin-mocha" + Name string // display name shown in the theme picker Colors Colors Accents []Accent // optional accent variants; first = default (matches Colors.Accent) } @@ -118,67 +125,67 @@ var catppuccinMacchiatoAccents = []Accent{ var Builtins = []Builtin{ { ID: "catppuccin-frappe", Name: "Catppuccin Frappe", - Colors: Colors{Accent: "#ca9ee6", Background: "#303446", Surface: "#414559", SurfaceAlt: "#51576d", Text: "#c6d0f5", TextDim: "#a5adce", Border: "#626880"}, + Colors: Colors{Accent: "#ca9ee6", Background: "#303446", Surface: "#414559", SurfaceAlt: "#51576d", Text: "#c6d0f5", TextDim: "#a5adce", Border: "#626880", Error: "#e78284"}, Accents: catppuccinFrappeAccents, }, { ID: "catppuccin-latte", Name: "Catppuccin Latte", - Colors: Colors{Accent: "#8839ef", Background: "#eff1f5", Surface: "#e6e9ef", SurfaceAlt: "#dce0e8", Text: "#4c4f69", TextDim: "#7c7f93", Border: "#bcc0cc"}, + Colors: Colors{Accent: "#8839ef", Background: "#eff1f5", Surface: "#e6e9ef", SurfaceAlt: "#dce0e8", Text: "#4c4f69", TextDim: "#7c7f93", Border: "#bcc0cc", Error: "#d20f39"}, Accents: catppuccinLatteAccents, }, { ID: "catppuccin-macchiato", Name: "Catppuccin Macchiato", - Colors: Colors{Accent: "#c6a0f6", Background: "#24273a", Surface: "#363a4f", SurfaceAlt: "#494d64", Text: "#cad3f5", TextDim: "#a5adcb", Border: "#5b6078"}, + Colors: Colors{Accent: "#c6a0f6", Background: "#24273a", Surface: "#363a4f", SurfaceAlt: "#494d64", Text: "#cad3f5", TextDim: "#a5adcb", Border: "#5b6078", Error: "#ed8796"}, Accents: catppuccinMacchiatoAccents, }, { ID: "catppuccin-mocha", Name: "Catppuccin Mocha", - Colors: Colors{Accent: "#cba6f7", Background: "#1e1e2e", Surface: "#313244", SurfaceAlt: "#45475a", Text: "#cdd6f4", TextDim: "#a6adc8", Border: "#585b70"}, + Colors: Colors{Accent: "#cba6f7", Background: "#1e1e2e", Surface: "#313244", SurfaceAlt: "#45475a", Text: "#cdd6f4", TextDim: "#a6adc8", Border: "#585b70", Error: "#f38ba8"}, Accents: catppuccinMochaAccents, }, { ID: "everforest-light", Name: "Everforest Light", - Colors: Colors{Accent: "#8da101", Background: "#fdf6e3", Surface: "#f4f0d9", SurfaceAlt: "#efebd4", Text: "#5c6a72", TextDim: "#859289", Border: "#bdc3af"}, + Colors: Colors{Accent: "#8da101", Background: "#fdf6e3", Surface: "#f4f0d9", SurfaceAlt: "#efebd4", Text: "#5c6a72", TextDim: "#859289", Border: "#bdc3af", Error: "#f85552"}, }, { ID: "github-light", Name: "GitHub Light", - Colors: Colors{Accent: "#0969da", Background: "#ffffff", Surface: "#f6f8fa", SurfaceAlt: "#f3f4f6", Text: "#1f2328", TextDim: "#59636e", Border: "#d1d9e0"}, + Colors: Colors{Accent: "#0969da", Background: "#ffffff", Surface: "#f6f8fa", SurfaceAlt: "#f3f4f6", Text: "#1f2328", TextDim: "#59636e", Border: "#d1d9e0", Error: "#cf222e"}, }, { ID: "gruvbox-dark", Name: "Gruvbox Dark", - Colors: Colors{Accent: "#fe8019", Background: "#282828", Surface: "#3c3836", SurfaceAlt: "#504945", Text: "#ebdbb2", TextDim: "#a89984", Border: "#665c54"}, + Colors: Colors{Accent: "#fe8019", Background: "#282828", Surface: "#3c3836", SurfaceAlt: "#504945", Text: "#ebdbb2", TextDim: "#a89984", Border: "#665c54", Error: "#fb4934"}, }, { ID: "gruvbox-light", Name: "Gruvbox Light", - Colors: Colors{Accent: "#d65d0e", Background: "#fbf1c7", Surface: "#ebdbb2", SurfaceAlt: "#d5c4a1", Text: "#3c3836", TextDim: "#7c6f64", Border: "#bdae93"}, + Colors: Colors{Accent: "#d65d0e", Background: "#fbf1c7", Surface: "#ebdbb2", SurfaceAlt: "#d5c4a1", Text: "#3c3836", TextDim: "#7c6f64", Border: "#bdae93", Error: "#9d0006"}, }, { ID: "nord", Name: "Nord", - Colors: Colors{Accent: "#88c0d0", Background: "#2e3440", Surface: "#3b4252", SurfaceAlt: "#434c5e", Text: "#eceff4", TextDim: "#d8dee9", Border: "#4c566a"}, + Colors: Colors{Accent: "#88c0d0", Background: "#2e3440", Surface: "#3b4252", SurfaceAlt: "#434c5e", Text: "#eceff4", TextDim: "#d8dee9", Border: "#4c566a", Error: "#bf616a"}, }, { ID: "one-light", Name: "One Light", - Colors: Colors{Accent: "#4078f2", Background: "#fafafa", Surface: "#f0f0f0", SurfaceAlt: "#e5e5e6", Text: "#383a42", TextDim: "#696c77", Border: "#d0d0d0"}, + Colors: Colors{Accent: "#4078f2", Background: "#fafafa", Surface: "#f0f0f0", SurfaceAlt: "#e5e5e6", Text: "#383a42", TextDim: "#696c77", Border: "#d0d0d0", Error: "#e45649"}, }, { ID: "rog-dark", Name: "ROG Dark", - Colors: Colors{Accent: "#cc0000", Background: "#1a1a1a", Surface: "#2a2a2a", SurfaceAlt: "#333333", Text: "#e0e0e0", TextDim: "#888888", Border: "#444444"}, + Colors: Colors{Accent: "#cc0000", Background: "#1a1a1a", Surface: "#2a2a2a", SurfaceAlt: "#333333", Text: "#e0e0e0", TextDim: "#888888", Border: "#444444", Error: "#ff4444"}, }, { ID: "rog-neon", Name: "ROG Neon", - Colors: Colors{Accent: "#00d4ff", Background: "#0d0d14", Surface: "#1a1a2e", SurfaceAlt: "#16213e", Text: "#e0e0f0", TextDim: "#8888aa", Border: "#2a2a4a"}, + Colors: Colors{Accent: "#00d4ff", Background: "#0d0d14", Surface: "#1a1a2e", SurfaceAlt: "#16213e", Text: "#e0e0f0", TextDim: "#8888aa", Border: "#2a2a4a", Error: "#ff3366"}, }, { ID: "rose-pine-dawn", Name: "Rose Pine Dawn", - Colors: Colors{Accent: "#d7827e", Background: "#faf4ed", Surface: "#f2e9e1", SurfaceAlt: "#ede3e0", Text: "#575279", TextDim: "#9893a5", Border: "#dfdad9"}, + Colors: Colors{Accent: "#d7827e", Background: "#faf4ed", Surface: "#f2e9e1", SurfaceAlt: "#ede3e0", Text: "#575279", TextDim: "#9893a5", Border: "#dfdad9", Error: "#b4637a"}, }, { ID: "solarized-light", Name: "Solarized Light", - Colors: Colors{Accent: "#268bd2", Background: "#fdf6e3", Surface: "#eee8d5", SurfaceAlt: "#e8e2ce", Text: "#657b83", TextDim: "#839496", Border: "#d3c9b0"}, + Colors: Colors{Accent: "#268bd2", Background: "#fdf6e3", Surface: "#eee8d5", SurfaceAlt: "#e8e2ce", Text: "#657b83", TextDim: "#839496", Border: "#d3c9b0", Error: "#dc322f"}, }, { ID: "tokyo-night", Name: "Tokyo Night", - Colors: Colors{Accent: "#7aa2f7", Background: "#1a1b26", Surface: "#24283b", SurfaceAlt: "#2f3549", Text: "#c0caf5", TextDim: "#565f89", Border: "#292e42"}, + Colors: Colors{Accent: "#7aa2f7", Background: "#1a1b26", Surface: "#24283b", SurfaceAlt: "#2f3549", Text: "#c0caf5", TextDim: "#565f89", Border: "#292e42", Error: "#f7768e"}, }, } diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go index 333b6f6..375522b 100644 --- a/internal/theme/theme_test.go +++ b/internal/theme/theme_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package theme import "testing" @@ -22,7 +25,8 @@ func TestBuiltinsAllColorsSet(t *testing.T) { for _, b := range Builtins { c := b.Colors if c.Accent == "" || c.Background == "" || c.Surface == "" || - c.SurfaceAlt == "" || c.Text == "" || c.TextDim == "" || c.Border == "" { + c.SurfaceAlt == "" || c.Text == "" || c.TextDim == "" || c.Border == "" || + c.Error == "" { t.Errorf("theme %q has empty color fields", b.ID) } } @@ -116,6 +120,7 @@ func TestDefaultColorsValid(t *testing.T) { {"Text", c.Text}, {"TextDim", c.TextDim}, {"Border", c.Border}, + {"Error", c.Error}, } { if !IsHexColor(pair.val) { t.Errorf("DefaultColors.%s = %q is not a valid hex color", pair.name, pair.val) diff --git a/internal/togglegate/togglegate.go b/internal/togglegate/togglegate.go index df1743c..65a3e76 100644 --- a/internal/togglegate/togglegate.go +++ b/internal/togglegate/togglegate.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package togglegate import "time" diff --git a/internal/togglegate/togglegate_test.go b/internal/togglegate/togglegate_test.go index 7799f1a..8d9386a 100644 --- a/internal/togglegate/togglegate_test.go +++ b/internal/togglegate/togglegate_test.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package togglegate import ( diff --git a/internal/uiscale/uiscale.go b/internal/uiscale/uiscale.go new file mode 100644 index 0000000..d41850b --- /dev/null +++ b/internal/uiscale/uiscale.go @@ -0,0 +1,96 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +// Package uiscale computes the drawer's UI scale factor for the gamescope +// backend, where GTK cannot be asked to scale for us. +// +// GDK_SCALE is unusable here: GTK would scale its buffer and gamescope's own +// scaler would then scale that again. So the drawer scales its CSS instead, and +// this package decides by how much. +// +// It is a separate package because internal/gui/gamescope imports cgo (Xlib), and +// a cgo package cannot be unit tested without the C toolchain and headers — while +// this is pure arithmetic with clamping and an env-var override, exactly the sort +// of thing worth pinning down. +package uiscale + +import ( + "math" + "strconv" +) + +const ( + // ReferenceWidth is the output width at which scale is 1.0: 2560/1.5, which + // makes the drawer occupy the same fraction of the screen as KDE at 150% on + // the Z13's native panel. + ReferenceWidth = 1707.0 + + // Min is the smallest usable scale: below it touch targets fall under the + // minimum comfortable size. + Min = 1.0 + // Max is the largest usable scale: beyond it the drawer stops fitting on + // screen, and an unreachable dismiss control cannot be recovered from. + Max = 3.0 +) + +// For returns the UI scale for an output of the given pixel width. +// +// envOverride is the raw Z13GUI_SCALE value ("" when unset). When it parses as a +// positive number it replaces the computed scale — but is still clamped to +// [Min, Max]. That is deliberate: the override exists to correct a bad +// auto-detection, not to allow an unusable UI, and a typo like "30" instead of +// "3.0" should not produce a drawer nothing can dismiss. +// +// A malformed or non-positive override is ignored in favour of auto-detection, +// since silently falling back beats starting with an unusable interface. +// +// A non-positive width means the monitor geometry was not available; Min is the +// safe answer. +func For(outputWidth int, envOverride string) float64 { + if v, ok := parseOverride(envOverride); ok { + return clamp(v) + } + if outputWidth <= 0 { + return Min + } + return clamp(float64(outputWidth) / ReferenceWidth) +} + +// OverrideIsUsable reports whether envOverride would be honoured by For. Callers +// use it to log that a supplied value was ignored, which is otherwise invisible. +func OverrideIsUsable(envOverride string) bool { + _, ok := parseOverride(envOverride) + return ok +} + +// OverrideWasClamped reports whether a usable override was outside [Min, Max] and +// therefore did not take effect as written — worth telling the user, since they +// asked for a specific number and got a different one. +func OverrideWasClamped(envOverride string) bool { + v, ok := parseOverride(envOverride) + return ok && v != clamp(v) +} + +func parseOverride(s string) (float64, bool) { + if s == "" { + return 0, false + } + v, err := strconv.ParseFloat(s, 64) + // NaN needs an explicit test: ParseFloat("NaN") succeeds, and every + // comparison against NaN is false, so a `v <= 0` guard lets it through and + // clamp() then passes it along untouched. +Inf is fine — clamp catches it. + if err != nil || math.IsNaN(v) || v <= 0 { + return 0, false + } + return v, true +} + +func clamp(v float64) float64 { + if v < Min { + return Min + } + if v > Max { + return Max + } + return v +} diff --git a/internal/uiscale/uiscale_test.go b/internal/uiscale/uiscale_test.go new file mode 100644 index 0000000..10de39d --- /dev/null +++ b/internal/uiscale/uiscale_test.go @@ -0,0 +1,117 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + +package uiscale + +import ( + "fmt" + "math" + "testing" +) + +func TestForAutoDetection(t *testing.T) { + tests := []struct { + width int + want float64 + }{ + {width: int(ReferenceWidth), want: 1.0}, // reference panel + {width: 2560, want: 2560 / ReferenceWidth}, // Z13 native + {width: 1920, want: 1920 / ReferenceWidth}, + {width: 1280, want: Min}, // small panel clamps up + {width: 800, want: Min}, + {width: 7680, want: Max}, // 8K clamps down + } + for _, tt := range tests { + t.Run(fmt.Sprint(tt.width), func(t *testing.T) { + got := For(tt.width, "") + if math.Abs(got-tt.want) > 1e-9 { + t.Errorf("For(%d, \"\") = %v, want %v", tt.width, got, tt.want) + } + }) + } +} + +// Monitor geometry is not always available at realize time; a zero width must not +// produce a zero or negative scale, which would collapse the drawer. +func TestForHandlesUnknownGeometry(t *testing.T) { + for _, w := range []int{0, -1, -4096} { + if got := For(w, ""); got != Min { + t.Errorf("For(%d, \"\") = %v, want %v", w, got, Min) + } + } +} + +func TestForHonoursTheOverride(t *testing.T) { + // The override replaces auto-detection entirely, including going below what + // the panel width would give. + if got := For(2560, "1.25"); got != 1.25 { + t.Errorf("For(2560, \"1.25\") = %v, want 1.25", got) + } + if got := For(1280, "2.5"); got != 2.5 { + t.Errorf("For(1280, \"2.5\") = %v, want 2.5", got) + } +} + +// Pins the intent that was previously implicit: an override is still clamped. A +// typo like "30" for "3.0" must not produce a drawer too large to dismiss. +func TestOverrideIsStillClamped(t *testing.T) { + if got := For(2560, "30"); got != Max { + t.Errorf("For(2560, \"30\") = %v, want %v (clamped)", got, Max) + } + if got := For(2560, "0.1"); got != Min { + t.Errorf("For(2560, \"0.1\") = %v, want %v (clamped)", got, Min) + } + if !OverrideWasClamped("30") { + t.Error("OverrideWasClamped(\"30\") = false, want true so the user can be told") + } + if OverrideWasClamped("2.0") { + t.Error("OverrideWasClamped(\"2.0\") = true, want false") + } + if OverrideWasClamped("") { + t.Error("OverrideWasClamped(\"\") = true, want false") + } +} + +// A malformed override falls back to auto-detection rather than to some fixed +// value — starting with a wrong-but-usable UI beats an unusable one. +func TestMalformedOverrideFallsBackToAutoDetection(t *testing.T) { + auto := For(2560, "") + for _, bad := range []string{"abc", "1.0.0", "", " ", "1,5", "NaN-ish", "-2", "0"} { + if got := For(2560, bad); got != auto { + t.Errorf("For(2560, %q) = %v, want the auto-detected %v", bad, got, auto) + } + if OverrideIsUsable(bad) { + t.Errorf("OverrideIsUsable(%q) = true, want false", bad) + } + } + for _, good := range []string{"1.5", "2", "0.5", " "} { + _ = good // " " is covered above; listed here to document the boundary + } + if !OverrideIsUsable("1.5") { + t.Error("OverrideIsUsable(\"1.5\") = false, want true") + } +} + +// Whatever the inputs, the result must be a usable scale. Anything outside the +// bounds means touch targets too small to hit or a drawer that does not fit. +func TestForAlwaysReturnsAUsableScale(t *testing.T) { + widths := []int{-1, 0, 1, 640, 1280, 1707, 1920, 2560, 3840, 7680, 1 << 20} + overrides := []string{"", "abc", "0", "-1", "0.001", "1", "1.5", "3", "3.001", "1000", "1e9", "NaN", "Inf"} + for _, w := range widths { + for _, o := range overrides { + got := For(w, o) + if math.IsNaN(got) || math.IsInf(got, 0) { + t.Fatalf("For(%d, %q) = %v, not a real number", w, o, got) + } + if got < Min || got > Max { + t.Errorf("For(%d, %q) = %v, outside [%v,%v]", w, o, got, Min, Max) + } + } + } +} + +func TestReferenceWidthGivesUnityScale(t *testing.T) { + if got := For(int(ReferenceWidth), ""); math.Abs(got-1.0) > 1e-9 { + t.Errorf("For(ReferenceWidth) = %v, want 1.0 — the constant defines the unity point", got) + } +} diff --git a/main.go b/main.go index 7140f93..1268891 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,6 @@ +// Copyright 2026 Jeff Hagadorn +// SPDX-License-Identifier: Apache-2.0 + package main // z13gui — GTK4 Wayland overlay drawer for z13ctl. @@ -13,6 +16,7 @@ import ( "github.com/dahui/z13gui/internal/gui" "github.com/dahui/z13gui/internal/gui/gamepad" + "github.com/dahui/z13gui/internal/startup" "github.com/dahui/z13gui/internal/theme" "github.com/diamondburned/gotk4/pkg/gtk/v4" ) @@ -21,29 +25,24 @@ import ( var Version = "dev" func main() { - // Scan args for our flags before GTK sees them. We cannot use flag.Parse() - // because app.Run() passes remaining args to GLib's option parser, which - // would error on any flags it doesn't recognize. - debug := false - gtkArgs := []string{os.Args[0]} - for _, arg := range os.Args[1:] { - switch arg { - case "--debug", "-d": - debug = true - case "--version": - fmt.Printf("z13gui %s\n", Version) - os.Exit(0) - case "--print-theme": - fmt.Print(gui.DefaultThemeTOML()) - os.Exit(0) - case "--list-themes": - for _, t := range theme.Builtins { - fmt.Printf("%-20s %s\n", t.ID, t.Name) - } - os.Exit(0) - default: - gtkArgs = append(gtkArgs, arg) + // Scan args for our flags before GTK sees them. The flag package cannot be + // used: app.Run() forwards the remainder to GLib's option parser, which errors + // on anything it does not recognise, so our flags must be removed from the + // slice rather than merely read. + args := startup.ParseArgs(os.Args) + switch args.Action { + case startup.ActionVersion: + fmt.Printf("z13gui %s\n", Version) + os.Exit(0) + case startup.ActionPrintTheme: + fmt.Print(gui.DefaultThemeTOML()) + os.Exit(0) + case startup.ActionListThemes: + for _, t := range theme.Builtins { + fmt.Printf("%-20s %s\n", t.ID, t.Name) } + os.Exit(0) + case startup.ActionNone: } // Configure slog with split-level filtering. gotk4's glib init() routes @@ -52,11 +51,11 @@ func main() { // default: app=Info, GTK=Warn (show app events, suppress GTK debug/info noise) // -d: app=Debug, GTK=Debug (show everything including GTK internals) appLevel, gtkLevel := slog.LevelInfo, slog.LevelWarn - if debug { + if args.Debug { appLevel, gtkLevel = slog.LevelDebug, slog.LevelDebug } text := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: appLevel}) - slog.SetDefault(slog.New(gui.NewFilterHandler(text, appLevel, gtkLevel))) + slog.SetDefault(slog.New(startup.NewFilterHandler(text, appLevel, gtkLevel))) slog.Info("starting", "version", Version) @@ -115,5 +114,5 @@ func main() { } win = gui.New(app) }) - os.Exit(app.Run(gtkArgs)) + os.Exit(app.Run(args.GTKArgs)) }