feat(display): per-output ICC color profiles and color temperature (#3388)

* feat(display): per-output ICC color profiles and color temperature

Rebuild of #3388 on current master: the previous branch was cut from an
older base and reverted unrelated fixes in the gamma stack and the display
settings UI.

ICC profiles:
- core/internal/icc: ICC v2/v4 parser (TRC, LUT8/16 and parametric curves,
  vcgt, description/version/color space) plus gamma ramp generation, with
  synthetic coverage and optional vendor-profile tests (ICC_TEST_DIR).
- wayland manager: load iccProfiles from the compositor's DMS config dir,
  build the ramp from the profile when one is set (falling back to the
  temperature ramp when it is missing or unparsable), and re-apply on
  output hotplug and after resume.
- ICC ramps apply while the night light schedule is disabled; outputs
  without a profile keep an identity ramp.
- desc/mluc offsets are validated before slicing, so a malformed profile
  returns an error instead of panicking the daemon.

Per-output temperature:
- outputTemps in the same config; SetOutputTemp accepts 1000-10000K and the
  Display Config slider exposes 3000-10000K, which matters for displays
  calibrated at a higher white point (e.g. a 7000K profile).

Gamma control lifecycle:
- needsControls() now keeps the controls alive for configured ICC profiles
  and per-output temperatures, so toggling the night light off no longer
  destroys the controls and drops the ramps.
- Re-apply paths clear the per-output dedup state instead of adding a
  manager-level guard, which would have suppressed contrast-only writes and
  the post-resume forced resend (#1235), and left a recreated control
  without a ramp.
- Monitor sleep/disconnect still re-establishes controls when an output
  comes back.

IPC / CLI / UI:
- wayland.icc.{getStatus,apply,remove,listOutputs,setTemp,getTemps}
- dms icc list | info <file> | apply <output> <file> | remove <output> | status
- Display Config output card gains a Color Profile row (browse/apply/remove
  with description, version, color space and active state) and a per-output
  color temperature slider, backed by Services/ICCService.qml.

Config path:
- DMSConfigDir() follows the compositor layout (niri/dms, hypr/dms,
  mango/dms) via the shared compositor detection and utils.XDGConfigHome(),
  replacing the hardcoded niri path in both the manager and `dms icc`.

Tests: TZ=UTC go test ./... (60 packages ok), including red/green coverage
for the icc parser bounds checks and needsControls().

* feat(icc): add `dms icc set-temp` and show per-output temps in `status`

The per-output temperature override was reachable from the Display Config
slider and from `wayland.icc.setTemp` over IPC, but not from the CLI.

- `dms icc set-temp <output> <kelvin>` (alias `setTemp`) sets a per-output
  override, 1000-10000K, and 0 clears it so the output follows the night
  light schedule again. Range checking matches the daemon so an invalid
  value fails in the CLI instead of over IPC.
- `dms icc status` gains a Temp column (the override in K, or `schedule`),
  so an override can be verified without opening the settings UI.

Verified against a running daemon: `dms icc status` reports 7000K for the
three overridden outputs and `schedule` for the remaining one.

* feat(icc): treat the per-output temperature as the profile's reference white point

The per-output temperature was documented as the white point a profile was
produced at, but an output with a profile ignored it entirely: the ramp was
generated from the profile and the temperature only applied to outputs
without one (or as the fallback when a profile failed to parse). A display
calibrated at 7000K therefore got the profile ramp, and enabling the night
light had no effect on it at all.

- `ProfileRampWithTemp` composes the profile ramp with the ratio between the
  target temperature ramp and the reference ramp, so the temperature is the
  white point the profile describes and the night light shifts relative to
  it.
- `applyGamma` passes the night light temperature as the target, and
  `noTempTarget` when the schedule is disabled, which leaves profiled
  outputs at their reference white point and drives plain outputs with the
  neutral ramp.
- The re-apply sentinel stays 0, so it cannot collide with an applied
  temperature.

Covered by TestProfileRampWithTemp (reference == target is a no-op, warmer
and cooler targets move the expected channels).

Also: `dms icc set-temp` help text describes the reference-white-point
semantics.

* fix(icc): load the configured profiles even when controls already exist

Restoring the upstream "output returned, re-establishing controls" branch
made the registry handler establish the gamma controls before the startup
post runs, and that post returned early on `m.controlsInitialized`, so the
configured ICC profiles and per-output temperatures were never attached:
every output reported "(none)" and the night light ran with defaults until
the user re-applied a profile by hand.

- `initializeControlsAndICC` (the startup post) loads the configuration
  first and only creates the controls when they are missing.
- The loading is split into `loadConfiguredICC` / `applyConfiguredICCForOutput`
  so it can also run per output.
- A hotplugged output now gets its configured profile and temperature as soon
  as its name is known (name handler) or when its control is created, which
  also makes the "re-applies on hotplug" claim in the description true.
- After attaching, the output's dedup state is cleared so the ramp is written.

Covered by TestManager_LoadConfiguredICCWhenControlsAlreadyExist and
TestManager_AttachConfiguredICCForNamedOutput.

* fix(icc): make the per-output temperature an actual target

The per-output value was treated as the white point a profile was produced
at, which meant a display with a profile never changed when the user set a
temperature: the profile ramp was applied as measured and the value only
mattered when a night light target existed. Per-display temperatures were
therefore unusable on profiled outputs while the night light was disabled,
which is the normal state for users who want each monitor at its own value.

- `effectiveTempTarget` decides per output: a per-output override wins over
  the night light schedule, the schedule applies when there is no override,
  and `noTempTarget` means neither. `0` is "no override", not 0K.
- `applyGamma` composes the override on top of the profile ramp, treating the
  profile as measured at 6500K (D65), and drives outputs without a profile
  with the same target so both paths agree.
- `dms icc set-temp` help text and the Display Config slider description go
  back to describing an independent per-output temperature.

Covered by TestEffectiveTempTarget (override wins, schedule fallback, none)
and the updated TestProfileRampWithTemp (7000K cools, 5000K warms relative to
the 6500K reference).

* feat(icc): show the profile details in Display Config

The profile row only showed the description, so there was no way to tell
which profile a display actually has applied (whitepoint, curve type, file
provenance) without leaving the settings page.

- `ICCStatus` carries the descriptive metadata: class, tone-curve kind (plus
  gamma or table size), vcgt channels/entries, white point chromaticity with
  a derived CCT and standard illuminant name, file size and mtime.
- `internal/icc` gains `WhitePointXY`, `WhitePointCCT` (McCamy),
  `WhitePointName` (D50/D65) and `TRCKind`, all covered by tests.
- Display Config's Color Profile row gets an info button that opens
  `ICCProfileInfoModal` with those fields and the profile path, alongside
  the existing browse/remove actions.
- `dms icc info` reports the white point as name/xy/CCT instead of raw XYZ.

* fix(icc): keep the daemon, the state snapshot and the temp slider honest

A malformed profile could still take the daemon down, the ICC getters ran off
the wayland actor goroutine, the pushed state hid a removal, and the
per-output temperature slider dropped wheel changes.

- `internal/icc`: the tag table was sized with `uint32` arithmetic, so
  `tagCount*12` wrapped (`0x15555556*12 == 8`) and a 140-byte profile passed
  the bounds check, allocated ~4.3 GB and then panicked. `ParseFile` runs on
  the wayland actor goroutine, which has no `recover()`, so a bad profile
  persisted in `wayland.json` took the daemon down at startup. The count is now
  compared against the bytes that are actually left before anything is sized
  with it.
- The manager publishes an ICC snapshot (`iccStatus`/`iccTemps`) whenever the
  actor changes an output's profile or temperature, and `GetICCStatus`/
  `GetOutputTemps` serve that snapshot instead of ranging `m.outputs` and
  reading fields the actor owns. That removes the data race reported by
  `go test -race` for `dms icc status` running against a hotplug, and keeps the
  per-profile `os.Stat` off the night light animation path, where it ran on
  every `updateStateFromSchedule` (including each 25 K step).
- `State.ICCProfiles`/`OutputTemps` drop `omitempty`, so removing the last
  profile pushes `{}` instead of omitting the key: the QML truthiness guard
  then clears `ICCService.status` instead of leaving the removed profile on
  screen with a green "active" dot.
- `removeOutputByRegistryName` (extracted from the registry remove handler)
  deletes the `outputNames`/`outputRegNames` entries along with the output, so
  a disconnected monitor stops being listed by `dms icc status`/`listOutputs`,
  and a rebound `wl_output` reusing the released object ID cannot attach the
  previous monitor's profile from the stale name.
- The per-output temperature slider sets `wheelEnabled: false` like the other
  drag-finished sliders: `DankSlider`'s wheel handler assigns `value` and emits
  `sliderValueChanged` without `sliderDragFinished`, so scrolling dropped the
  change, froze the label on the scrolled value and destroyed the value binding.

Covered by TestParseBytesTagTableBounds (allocates 4,299,161,600 bytes at
parser.go:138 and dies with an unrecovered fatal error on the previous
commit), TestManager_ICCStatusServesPublishedSnapshot,
TestManager_RemoveOutputByRegistryNamePrunesNames and
TestManager_ICCStatusConcurrentPublishAndRead; a temporary copy of the getter
test against the previous commit reports DATA RACE and passes here.

* fix(icc): keep the attached profile when a gamma control is re-created

`setupOutputControls` stored a fresh `outputState` for every output it touched,
and `createControls` calls it whenever the controls are rebuilt, so recreating a
control dropped the output's `iccPath`/`iccProfile`/`outputTemp` — configured
state that does not belong to the control. The path is dormant today because
`destroyControls` only runs when nothing is configured (`needsControls()` covers
`ICCProfiles`/`OutputTemps`), but the snapshot published after `createControls`
would then report whatever the rebuilt state holds instead of the profile the
display is configured with.

`controlStateFor` reuses the state of a known output and resets only what belongs
to the new control: the ramp bookkeeping and the failure budget (rampSize,
failed, retryCount, lastFailTime, lastTemp, lastGamma, lastContrast). A control
that has not reported gamma_size yet has to receive its ramp again, while the
profile and the per-output temperature survive. An output seen for the first
time still starts from a clean state.

Covered by TestManager_ControlStateReuseKeepsAttachedICC.

* fix(icc): keep the config a real snapshot and make the per-output temperature apply

Third review round.

- `Config` is copied by value all over the manager, but `ICCProfiles` and
  `OutputTemps` are maps, so every copy aliased the ones the manager writes under
  `configMutex`. `updateStateFromSchedule` stored such a copy in `m.state` and it
  was marshalled on the connection writer goroutine while `ApplyICC` stored a
  profile from the IPC goroutine (`fatal error: concurrent map iteration and map
  write`, which no `recover()` catches); `SaveConfig` marshalled its copy after
  the unlock, and `loadConfiguredICC`/`attachConfiguredICC` indexed the maps after
  releasing the lock. Every copy that outlives the lock is cloned now
  (`cloneConfig`/`configSnapshot`), so a value copy is a snapshot again.
- `SetOutputTemp` never created the gamma controls. On a default install (night
  light off, gamma 1.0, contrast 1.0, no profile, no override) they do not exist,
  so the `Range` over `m.outputs` matched nothing: the value was written to
  `wayland.json`, no ramp was applied, and the settings slider snapped back to
  "Default" until a daemon restart. It now persists first, creates the controls
  through `ensureOutputControls` (shared with `ApplyICC`), and pushes the state
  from the actor, and the published temperatures report the configured value even
  before an output is known.
- A TRC is not a video card gamma ramp: `rTRC`/`gTRC`/`bTRC` describe the
  display's own transfer function, so a gamma-2.2 profile was written as
  `t^(1/2.2)` — mid gray 0.5 became 47909/65535 — washing the display out instead
  of correcting it. `ApplyICC` refuses a profile without `vcgt`, the attach path
  skips one with a warning, and `GenerateGammaRamp` returns an error instead of
  falling back to the TRC or to an identity ramp. TRC stays profile metadata
  (`dms icc info`, the settings dialog).
- `icc: applied ICC ramp` was logged at Info on every 25 K animation step; it is
  Debug now, which is where a per-step message belongs.
- `I18n.tr("No information available")` in the profile info modal passes its
  translator context, like every other string this PR adds.

Covered by TestManager_ConfigSnapshotOwnsItsMaps,
TestManager_ConfigSnapshotConcurrentMarshal (the previous commit dies with
`fatal error: concurrent map iteration and map write`),
TestManager_SetOutputTempWithoutControlsPublishesValue (the previous commit
reports 0 for the value it just stored), TestGenerateGammaRampRequiresVCGT, and
the updated TestProfileRampWithTemp, whose fixture now carries an identity vcgt
table instead of relying on an empty profile.

* fix(icc): push the state when only the ICC part changed

Found while sweeping the same class of problem: `stateChanged` compared only the
schedule fields, so a change that touches nothing else — applying or removing a
profile, `dms icc set-temp` — was treated as "no change" and the notifier never
pushed it. The settings page then only caught up because the UI path calls
`requestStatus()` afterwards, and the CLI path did not catch up at all, which is
the symptom the omitempty fix was meant to remove.

`iccStateChanged` compares the ICC profile status and the per-output
temperatures, so those changes reach the subscribers on their own. Covered by
the new TestStateChanged cases (profile applied, profile removed, profile
description changed, output temp changed, unchanged ICC state); the four
change cases report `stateChanged() = false` on the previous commit.

* fix(icc): keep the night light state, prune names without a control, use a 0 sentinel

Fifth review round.

- `SaveICCConfig` replaces `SaveConfig` on the three ICC paths (apply, remove,
  set-temp). Writing the daemon's whole config persisted the night light fields
  it happens to hold in memory: with the night light on, applying a profile
  stored `"Enabled": true`, and since the shell only ever sends
  `wayland.gamma.setEnabled` when the mode is on, turning it off afterwards (an
  in-memory change on the daemon side, no write) left that `true` on disk — after
  log out/in the daemon came up driving the schedule while the UI showed night
  mode off, with no way to resync short of a toggle. The ICC save now reads the
  file and rewrites only `iccProfiles`/`outputTemps`, so the fields the shell
  owns stay as they are.
- `removeOutputByRegistryName` resolved the output from `m.outputs`, which only
  has entries once the gamma controls exist. On a default install (night light
  off, no profile, no override) no control is ever created, so a disconnected
  monitor kept its `outputNames`/`outputRegNames` entries for the life of the
  daemon: `dms icc status`/`listOutputs` listed monitors that were gone, and both
  maps grew with every dock/undock cycle. The object ID is resolved from
  `m.outputRegNames` (populated for every `wl_output`), the name entries are
  dropped before the output state is looked up, and a state that is known without
  a registry name still falls back to matching its own.
- The Display Config temperature row used `7000` as its fallback for an output
  with no override. `publishICCState` only copies entries with `temp != 0`, so
  the value is absent rather than `0` for an unconfigured output: the card claimed
  "7000K" while the daemon drove the night light or neutral, and a user who
  deliberately set 7000K got the "no override" colouring because the highlight
  tested `!== 7000`. The sentinel is `0` now, which is what the `Default` label
  branches and the "is overridden" tests were written against.

Covered by TestSaveICCConfigKeepsNightLightFields (the previous commit brings the
night light back on after a restart), TestManager_RemoveOutputByRegistryNameWithoutControl
(the previous commit keeps an unplugged monitor listed) and the existing
TestManager_RemoveOutputByRegistryNamePrunesNames.

* fix(icc): validate the CLI arguments and the IPC parameters of the ICC commands

Sweep of the two surfaces the review has not looked at yet, so the failures are
found here instead of in the next round.

- `dms icc apply` copied the profile into the config directory before asking the
  daemon, and only the *parse* was checked locally. A profile the daemon refuses
  (no `vcgt` table, non-RGB color space) was therefore left in the profile
  directory for `dms icc list` to keep offering, and an IPC failure left the copy
  behind as well. The command now checks the same rules the daemon enforces
  before copying anything, and removes the copy it just made when the request
  fails (a file that was already there is left alone).
- `dms icc list` marks a profile without a `vcgt` table, so the rule that makes
  `apply` refuse it is visible where the profiles are listed.
- `ApplyICC`/`RemoveICC`/`SetOutputTemp` reach the manager over IPC with whatever
  the caller sent: an empty (or whitespace-padded) output name used to be
  persisted as an entry in `wayland.json` that no `wl_output` can ever match, and
  a relative profile path was stored to be resolved again on the next start, from
  a different working directory. Both are rejected now, before anything is
  written.

Verified with the built CLI against the previous binary: with a profile that has
no vcgt table, `dms icc apply` now exits before touching the config directory
(`residual=[]`) where the previous one copied the file and went on to the IPC
(`residual=[nogamma.icc]`); with a valid profile and no daemon reachable, the
previous one leaves `vcgt.icc` behind and this one does not. Covered by
TestManager_RejectsInvalidICCArguments.

* fix(icc): apply the user gamma and contrast on profiled outputs

* fix(icc): recreate failed controls without the night light and allow clearing a per-output temp

* test(icc): cover the recreated control and the cleared temperature override

Follow-up on d55d3d1f, which fixed both points without tests:

- TestManager_RecreateOutputControlFollowsNeedsControls pins the predicate: with
  the night light off and nothing configured there is nothing to recreate, and
  with a configured profile or a per-output temperature the control has to be
  recreated. Both configured cases return nil on 9226251c.
- TestManager_SetOutputTempZeroClearsTheOverride pins what the reset button in
  Display Config does end to end: the stored value and the published entry both
  disappear, so the row reads "Default" and the output follows the schedule.

Also keeps the reset button's slot instead of revealing it on demand. The
temperature row shares its column with the slider below it, so the height change
between "Default" (no button) and an override moved the slider 14 px under the
pointer while it was being dragged; the button is now always laid out and only
its opacity and enabled state follow the override, which also lets the label
width drop the conditional.

* fix(icc): replicate a one-channel vcgt and keep the temp slider in sync

* fix(icc): release idle gamma controls and push the output list

* fix(icc): dedupe ramps on the effective target

* fix(icc): never overwrite a same-named profile in the config dir

---------

Co-authored-by: bbedward <[email protected]>
This commit is contained in:
Minghao YU
2026-09-15 11:45:35 -04:00
committed by GitHub
co-authored by bbedward
parent c94ce9b49f
commit e809733e3a
19 changed files with 4608 additions and 78 deletions
+1
View File
@@ -773,6 +773,7 @@ func getCommonCommands() []*cobra.Command {
registryCmd,
dank16Cmd,
brightnessCmd,
iccCmd,
dpmsCmd,
keybindsCmd,
greeterCmd,
+566
View File
@@ -0,0 +1,566 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/icc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/spf13/cobra"
)
var iccCmd = &cobra.Command{
Use: "icc",
Short: "Manage ICC color profiles",
Long: "Manage ICC color profile assignments for displays",
}
var iccListCmd = &cobra.Command{
Use: "list",
Short: "List available ICC profiles and current assignments",
Args: cobra.NoArgs,
Run: runICCList,
}
var iccInfoCmd = &cobra.Command{
Use: "info <file>",
Short: "Show details of an ICC profile",
Args: cobra.ExactArgs(1),
Run: runICCInfo,
}
var iccApplyCmd = &cobra.Command{
Use: "apply <output> <file>",
Short: "Apply ICC profile to a specific output",
Args: cobra.ExactArgs(2),
Run: runICCApply,
}
var iccRemoveCmd = &cobra.Command{
Use: "remove <output>",
Short: "Remove ICC profile from a specific output",
Args: cobra.ExactArgs(1),
Run: runICCRemove,
}
var iccStatusCmd = &cobra.Command{
Use: "status",
Short: "Show ICC profile status for all outputs",
Args: cobra.NoArgs,
Run: runICCStatus,
}
var iccSetTempCmd = &cobra.Command{
Use: "set-temp <output> <kelvin>",
Aliases: []string{"setTemp"},
Short: "Set the color temperature for a specific output",
Long: "Set the color temperature for a specific output, independently of the\n" +
"night light schedule: every display keeps its own value, which also makes\n" +
"the range wider than the night light's.\n\n" +
"For an output with an ICC profile the temperature is composed on top of\n" +
"it, treating the profile as measured at 6500K.\n\n" +
"Accepts 1000-10000K; 0 removes the override, so the output follows the\n" +
"night light schedule again (6500K while it is disabled).",
Args: cobra.ExactArgs(2),
Run: runICCSetTemp,
}
func init() {
iccCmd.AddCommand(iccListCmd, iccInfoCmd, iccApplyCmd, iccRemoveCmd, iccStatusCmd, iccSetTempCmd)
}
func getICCConfigDir() string {
return wayland.ICCProfilesDir()
}
func listAvailableProfiles() []string {
dir := getICCConfigDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var profiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext == ".icc" || ext == ".icm" {
profiles = append(profiles, filepath.Join(dir, name))
}
}
return profiles
}
func runICCList(cmd *cobra.Command, args []string) {
// Get current assignments from running server
req := models.Request{
Method: "wayland.icc.getStatus",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get ICC status: %v", err)
}
statusMap := make(map[string]map[string]any)
outputs := []string{}
if resp.Result != nil {
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
var result map[string]any
if err := json.Unmarshal(resultBytes, &result); err == nil {
if outputsRaw, ok := result["outputs"].([]any); ok {
for _, o := range outputsRaw {
if s, ok := o.(string); ok {
outputs = append(outputs, s)
}
}
}
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
for k, v := range profilesRaw {
if m, ok := v.(map[string]any); ok {
statusMap[k] = m
}
}
}
}
}
}
// Show existing assignments
fmt.Println("Current ICC Assignments:")
fmt.Println(strings.Repeat("─", 80))
if len(outputs) == 0 && len(statusMap) == 0 {
fmt.Println(" (no outputs found)")
} else {
shown := make(map[string]bool)
for _, output := range outputs {
shown[output] = true
if status, ok := statusMap[output]; ok {
desc := ""
if d, ok := status["description"].(string); ok {
desc = d
}
path := ""
if p, ok := status["path"].(string); ok {
path = p
}
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
} else {
fmt.Printf(" %-20s %-30s %s\n", output, "(none)", "")
}
}
// Show any outputs that are in statusMap but not in outputs list
for output, status := range statusMap {
if shown[output] {
continue
}
desc := ""
if d, ok := status["description"].(string); ok {
desc = d
}
path := ""
if p, ok := status["path"].(string); ok {
path = p
}
fmt.Printf(" %-20s %-30s %s\n", output, desc, path)
}
}
// Show available profiles in config dir
fmt.Println()
fmt.Println("Available Profiles in", getICCConfigDir()+":")
fmt.Println(strings.Repeat("─", 80))
profiles := listAvailableProfiles()
if len(profiles) == 0 {
fmt.Println(" (no .icc/.icm files found)")
} else {
for _, path := range profiles {
profile, err := icc.ParseFile(path)
if err != nil {
fmt.Printf(" %-40s (parse error: %v)\n", filepath.Base(path), err)
continue
}
// A profile without a vcgt table has no video card gamma ramp, so the
// daemon refuses to apply it: say so here instead of letting the user
// find out from a rejected `dms icc apply`.
note := ""
if !profile.HasVCGT {
note = " (no vcgt table: cannot be applied)"
}
fmt.Printf(" %-40s %s%s\n", filepath.Base(path), profile.Description, note)
}
}
}
func runICCInfo(cmd *cobra.Command, args []string) {
path := args[0]
profile, err := icc.ParseFile(path)
if err != nil {
log.Fatalf("Failed to parse ICC profile: %v", err)
}
fmt.Printf("ICC Profile: %s\n", filepath.Base(path))
fmt.Println(strings.Repeat("─", 60))
fmt.Printf(" Description: %s\n", profile.Description)
fmt.Printf(" Version: %s\n", profile.Version)
fmt.Printf(" Class: %s\n", profile.Class)
fmt.Printf(" Color Space: %s\n", profile.ColorSpace)
fmt.Println()
if profile.HasMatrix {
fmt.Println("Matrix:")
for i := 0; i < 3; i++ {
fmt.Printf(" %8.4f %8.4f %8.4f\n",
profile.Matrix[i][0], profile.Matrix[i][1], profile.Matrix[i][2])
}
fmt.Println()
}
if profile.HasTRC {
fmt.Println("TRC (Tone Reproduction Curves):")
trcNames := []string{" Red", " Green", " Blue"}
for i, name := range trcNames {
curve := profile.TRC[i]
switch curve.Type {
case icc.CurveIdentity:
fmt.Printf("%-8s identity\n", name)
case icc.CurveParametric:
fmt.Printf("%-8s gamma=%.2f\n", name, curve.Gamma)
case icc.CurveTable:
fmt.Printf("%-8s table (%d entries)\n", name, len(curve.Entries))
}
}
fmt.Println()
}
if profile.HasVCGT && profile.VCGT != nil {
fmt.Printf("VCGT: %d channels, %d entries per channel\n", profile.VCGT.Channels, profile.VCGT.Entries)
fmt.Println()
}
if x, y, ok := profile.WhitePointXY(); ok {
cct := profile.WhitePointCCT()
name := profile.WhitePointName()
switch {
case name != "" && cct > 0:
fmt.Printf("White Point: %s (%.4f, %.4f, %dK)\n", name, x, y, cct)
case cct > 0:
fmt.Printf("White Point: (%.4f, %.4f, %dK)\n", x, y, cct)
default:
fmt.Printf("White Point: (%.4f, %.4f)\n", x, y)
}
} else {
fmt.Printf("White Point: %.4f %.4f %.4f\n",
profile.WhitePoint[0], profile.WhitePoint[1], profile.WhitePoint[2])
}
}
func runICCApply(cmd *cobra.Command, args []string) {
outputName := args[0]
filePath := args[1]
// Resolve to absolute path
if !filepath.IsAbs(filePath) {
abs, err := filepath.Abs(filePath)
if err != nil {
log.Fatalf("Failed to resolve path: %v", err)
}
filePath = abs
}
// Validate file exists and is a valid ICC profile. The rules the daemon
// enforces are checked here too: a profile it would refuse must not be copied
// into the profile directory first, or `dms icc list` keeps offering a file
// that can never be applied.
profile, err := icc.ParseFile(filePath)
if err != nil {
log.Fatalf("Invalid ICC profile: %v", err)
}
if profile.ColorSpace != "RGB" {
log.Fatalf("Unsupported color space %q, only RGB profiles can be applied", profile.ColorSpace)
}
if !profile.HasVCGT {
log.Fatalf("%s has no vcgt table, so it has no gamma ramp to apply", filepath.Base(filePath))
}
// Copy the ICC file to config dir for persistence
configDir := getICCConfigDir()
if err := os.MkdirAll(configDir, 0755); err != nil {
log.Fatalf("Failed to create config directory: %v", err)
}
destPath, copied, err := stageProfile(configDir, filePath)
if err != nil {
log.Fatalf("Failed to copy ICC file: %v", err)
}
// Send IPC request to running server
req := models.Request{
Method: "wayland.icc.apply",
Params: map[string]any{
"output": outputName,
"path": destPath,
},
}
resp, err := sendServerRequest(req)
if err != nil {
dropCopiedProfile(destPath, copied)
log.Fatalf("Failed to apply ICC profile: %v", err)
}
if resp.Error != "" {
dropCopiedProfile(destPath, copied)
log.Fatalf("Server error: %s", resp.Error)
}
fmt.Printf("Applied ICC profile '%s' to output '%s'\n", profile.Description, outputName)
}
// stageProfile places filePath in configDir and reports the path the daemon
// should load and whether a new file was written. A file already in configDir
// is used as it is. An existing file with the same name keeps its content: an
// identical copy is reused, a different one gets a numbered sibling, so two
// displays never end up sharing one file by name.
func stageProfile(configDir, filePath string) (string, bool, error) {
if filepath.Dir(filepath.Clean(filePath)) == filepath.Clean(configDir) {
return filePath, false, nil
}
data, err := os.ReadFile(filePath)
if err != nil {
return "", false, err
}
base := filepath.Base(filePath)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
for n := 0; ; n++ {
name := base
if n > 0 {
name = fmt.Sprintf("%s-%d%s", stem, n, ext)
}
destPath := filepath.Join(configDir, name)
existing, err := os.ReadFile(destPath)
switch {
case os.IsNotExist(err):
if err := os.WriteFile(destPath, data, 0644); err != nil {
return "", false, err
}
return destPath, true, nil
case err != nil:
return "", false, err
case bytes.Equal(existing, data):
return destPath, false, nil
}
}
}
// dropCopiedProfile removes the copy this command just made, so a rejected apply
// does not leave a profile in the config directory that was never applied (a
// file that was already there is left alone).
func dropCopiedProfile(path string, copied bool) {
if !copied {
return
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Errorf("Failed to remove %s: %v", path, err)
}
}
func runICCRemove(cmd *cobra.Command, args []string) {
outputName := args[0]
req := models.Request{
Method: "wayland.icc.remove",
Params: map[string]any{
"output": outputName,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to remove ICC profile: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
fmt.Printf("Removed ICC profile from output '%s'\n", outputName)
}
func runICCSetTemp(cmd *cobra.Command, args []string) {
outputName := args[0]
temp, err := strconv.Atoi(args[1])
if err != nil {
log.Fatalf("Invalid temperature %q: expected a number in kelvin", args[1])
}
if temp != 0 && (temp < 1000 || temp > 10000) {
log.Fatalf("Temperature %d out of range (1000-10000, or 0 to follow the schedule)", temp)
}
req := models.Request{
Method: "wayland.icc.setTemp",
Params: map[string]any{
"output": outputName,
"temp": temp,
},
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to set output temperature: %v", err)
}
if resp.Error != "" {
log.Fatalf("Server error: %s", resp.Error)
}
if temp == 0 {
fmt.Printf("Output '%s' follows the night light schedule again\n", outputName)
return
}
fmt.Printf("Set output '%s' color temperature to %dK\n", outputName, temp)
}
// fetchICCOutputTemps asks the running server for the per-output overrides.
// Outputs without an override report 0.
func fetchICCOutputTemps() map[string]int {
temps := make(map[string]int)
resp, err := sendServerRequest(models.Request{Method: "wayland.icc.getTemps"})
if err != nil || resp.Result == nil {
return temps
}
resultBytes, err := json.Marshal(*resp.Result)
if err != nil {
return temps
}
var raw map[string]any
if err := json.Unmarshal(resultBytes, &raw); err != nil {
return temps
}
for output, value := range raw {
if f, ok := value.(float64); ok {
temps[output] = int(f)
}
}
return temps
}
// formatOutputTemp renders a per-output override, or the schedule marker.
func formatOutputTemp(temps map[string]int, output string) string {
temp, ok := temps[output]
if !ok || temp == 0 {
return "schedule"
}
return strconv.Itoa(temp) + "K"
}
func runICCStatus(cmd *cobra.Command, args []string) {
req := models.Request{
Method: "wayland.icc.getStatus",
}
resp, err := sendServerRequest(req)
if err != nil {
log.Fatalf("Failed to get ICC status: %v", err)
}
statusMap := make(map[string]map[string]any)
outputs := []string{}
if resp.Result != nil {
if resultBytes, err := json.Marshal(*resp.Result); err == nil {
var result map[string]any
if err := json.Unmarshal(resultBytes, &result); err == nil {
if outputsRaw, ok := result["outputs"].([]any); ok {
for _, o := range outputsRaw {
if s, ok := o.(string); ok {
outputs = append(outputs, s)
}
}
}
if profilesRaw, ok := result["profiles"].(map[string]any); ok {
for k, v := range profilesRaw {
if m, ok := v.(map[string]any); ok {
statusMap[k] = m
}
}
}
}
}
}
temps := fetchICCOutputTemps()
fmt.Println("ICC Profile Status:")
fmt.Println(strings.Repeat("─", 100))
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", "Output", "Profile", "Version", "ColorSpace", "Active", "Temp")
fmt.Println(" " + strings.Repeat("─", 96))
shown := make(map[string]bool)
for _, output := range outputs {
shown[output] = true
if status, ok := statusMap[output]; ok {
active := false
if a, ok := status["active"].(bool); ok {
active = a
}
desc := "(none)"
if d, ok := status["description"].(string); ok && d != "" {
desc = d
}
version := ""
if v, ok := status["version"].(string); ok {
version = v
}
colorSpace := ""
if c, ok := status["colorSpace"].(string); ok {
colorSpace = c
}
activeStr := "no"
if active {
activeStr = "yes"
}
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
} else {
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, "(none)", "", "", "no", formatOutputTemp(temps, output))
}
}
// Show any outputs that are in statusMap but not in outputs list
for output, status := range statusMap {
if shown[output] {
continue
}
active := false
if a, ok := status["active"].(bool); ok {
active = a
}
desc := "(none)"
if d, ok := status["description"].(string); ok && d != "" {
desc = d
}
version := ""
if v, ok := status["version"].(string); ok {
version = v
}
colorSpace := ""
if c, ok := status["colorSpace"].(string); ok {
colorSpace = c
}
activeStr := "no"
if active {
activeStr = "yes"
}
fmt.Printf(" %-20s %-20s %-10s %-10s %-7s %-9s\n", output, desc, version, colorSpace, activeStr, formatOutputTemp(temps, output))
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestStageProfile(t *testing.T) {
configDir := t.TempDir()
srcDir := t.TempDir()
write := func(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
read := func(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
first := filepath.Join(srcDir, "dp1", "display.icc")
second := filepath.Join(srcDir, "dp2", "display.icc")
write(t, first, "profile for DP-1")
write(t, second, "profile for DP-2")
dest1, copied, err := stageProfile(configDir, first)
if err != nil {
t.Fatalf("stageProfile(first): %v", err)
}
if !copied || dest1 != filepath.Join(configDir, "display.icc") {
t.Fatalf("first copy: got %q copied=%v", dest1, copied)
}
dest2, copied, err := stageProfile(configDir, second)
if err != nil {
t.Fatalf("stageProfile(second): %v", err)
}
if !copied || dest2 != filepath.Join(configDir, "display-1.icc") {
t.Fatalf("same-named different profile must get a sibling: got %q copied=%v", dest2, copied)
}
if got := read(t, dest1); got != "profile for DP-1" {
t.Fatalf("first copy was overwritten: %q", got)
}
if got := read(t, dest2); got != "profile for DP-2" {
t.Fatalf("second copy has wrong content: %q", got)
}
again, copied, err := stageProfile(configDir, second)
if err != nil {
t.Fatalf("stageProfile(second again): %v", err)
}
if copied || again != dest2 {
t.Fatalf("an identical copy must be reused, not rewritten: got %q copied=%v", again, copied)
}
inPlace, copied, err := stageProfile(configDir, dest1)
if err != nil {
t.Fatalf("stageProfile(in place): %v", err)
}
if copied || inPlace != dest1 {
t.Fatalf("a file already in the profile directory is used as it is: got %q copied=%v", inPlace, copied)
}
if _, _, err := stageProfile(configDir, filepath.Join(srcDir, "missing.icc")); err == nil {
t.Fatal("expected an error for a missing source")
}
}
+72
View File
@@ -0,0 +1,72 @@
package icc
import (
"math"
"testing"
)
func TestWhitePointCCT(t *testing.T) {
cases := []struct {
name string
wp [3]float64
wantCCT int
wantName string
}{
{"D65", [3]float64{0.9505, 1.0, 1.0890}, 6504, "D65"},
{"D50", [3]float64{0.9642, 1.0, 0.8249}, 5003, "D50"},
{"missing white point", [3]float64{}, 0, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &Profile{WhitePoint: tc.wp}
cct := p.WhitePointCCT()
if tc.wantCCT == 0 {
if cct != 0 {
t.Fatalf("WhitePointCCT() = %d, want 0", cct)
}
} else if math.Abs(float64(cct-tc.wantCCT)) > 150 {
t.Fatalf("WhitePointCCT() = %d, want ~%d", cct, tc.wantCCT)
}
if got := p.WhitePointName(); got != tc.wantName {
t.Fatalf("WhitePointName() = %q, want %q", got, tc.wantName)
}
})
}
}
func TestTRCKind(t *testing.T) {
identity := Curve{Type: CurveIdentity}
gamma22 := Curve{Type: CurveParametric, Gamma: 2.2}
gamma18 := Curve{Type: CurveParametric, Gamma: 1.8}
table := Curve{Type: CurveTable, Entries: make([]uint16, 1024)}
tableShort := Curve{Type: CurveTable, Entries: make([]uint16, 256)}
cases := []struct {
name string
profile Profile
wantKind string
wantGamma float64
wantEntries int
}{
{"identity", Profile{HasTRC: true, TRC: [3]Curve{identity, identity, identity}}, "identity", 0, 0},
{"gamma", Profile{HasTRC: true, TRC: [3]Curve{gamma22, gamma22, gamma22}}, "gamma", 2.2, 0},
{"table", Profile{HasTRC: true, TRC: [3]Curve{table, table, table}}, "table", 0, 1024},
{"mixed types", Profile{HasTRC: true, TRC: [3]Curve{gamma22, table, gamma22}}, "mixed", 0, 0},
{"per-channel gamma", Profile{HasTRC: true, TRC: [3]Curve{gamma22, gamma18, gamma22}}, "mixed", 0, 0},
{"per-channel table size", Profile{HasTRC: true, TRC: [3]Curve{table, tableShort, table}}, "mixed", 0, 0},
{"no TRC", Profile{}, "", 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
kind, gamma, entries := tc.profile.TRCKind()
if kind != tc.wantKind || gamma != tc.wantGamma || entries != tc.wantEntries {
t.Fatalf("TRCKind() = (%q, %v, %d), want (%q, %v, %d)",
kind, gamma, entries, tc.wantKind, tc.wantGamma, tc.wantEntries)
}
})
}
}
+705
View File
@@ -0,0 +1,705 @@
// Package icc provides a pure-Go ICC color profile parser and gamma ramp generator.
// It supports ICC v2/v4 profile binary format and generates wlr-gamma-control
// compatible gamma ramps for display calibration.
package icc
import (
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"unicode/utf16"
)
// CurveType represents the type of a tone reproduction curve.
type CurveType int
const (
// CurveIdentity represents an identity curve (count=0, output equals input).
CurveIdentity CurveType = iota
// CurveParametric represents a single gamma value (count=1).
CurveParametric
// CurveTable represents a lookup table (count>1).
CurveTable
)
// Profile represents a parsed ICC color profile.
type Profile struct {
Size uint32
Version string // "2.1.0", "4.3.0"
Class string // "mntr", "scnr", "prtr"
ColorSpace string // "RGB", "CMYK"
Description string // from desc tag
Matrix [3][3]float64 // rXYZ, gXYZ, bXYZ columns
HasMatrix bool
TRC [3]Curve // rTRC, gTRC, bTRC
HasTRC bool
VCGT *VCGT // video card gamma table
HasVCGT bool
WhitePoint [3]float64 // XYZ of white point
}
// Curve represents a tone reproduction curve.
type Curve struct {
Type CurveType // Parametric or Table
Gamma float64 // if Parametric (count=1)
Entries []uint16 // if Table (0-65535), evenly spaced 0.0 to 1.0
}
// VCGT represents a video card gamma table.
type VCGT struct {
Channels int // 3 for RGB
Entries int // number of entries per channel
Red []uint16 // 0-65535
Green []uint16
Blue []uint16
}
// GammaRamp is the output format for wlr-gamma-control.
type GammaRamp struct {
Red []uint16
Green []uint16
Blue []uint16
}
// tagEntry represents a single entry in the ICC tag table.
type tagEntry struct {
sig [4]byte
offset uint32
size uint32
}
// tag signatures
var (
sigDesc = [4]byte{'d', 'e', 's', 'c'}
sigRXYZ = [4]byte{'r', 'X', 'Y', 'Z'}
sigGXYZ = [4]byte{'g', 'X', 'Y', 'Z'}
sigBXYZ = [4]byte{'b', 'X', 'Y', 'Z'}
sigRTRC = [4]byte{'r', 'T', 'R', 'C'}
sigGTRC = [4]byte{'g', 'T', 'R', 'C'}
sigBTRC = [4]byte{'b', 'T', 'R', 'C'}
sigVCGT = [4]byte{'v', 'c', 'g', 't'}
sigWtpt = [4]byte{'w', 't', 'p', 't'}
)
// ParseFile reads and parses an ICC profile from a file path.
func ParseFile(path string) (*Profile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("icc: failed to read file: %w", err)
}
return ParseBytes(data)
}
// ParseBytes parses an ICC profile from raw bytes.
func ParseBytes(data []byte) (*Profile, error) {
if len(data) < 128 {
return nil, errors.New("icc: profile data too short for header (< 128 bytes)")
}
p := &Profile{}
// Parse header
p.Size = binary.BigEndian.Uint32(data[0:4])
// Verify we have enough data
if uint32(len(data)) < p.Size {
return nil, fmt.Errorf("icc: profile data shorter than declared size: got %d, declared %d", len(data), p.Size)
}
// Parse version (bytes 8-11)
versionRaw := binary.BigEndian.Uint32(data[8:12])
major := (versionRaw >> 24) & 0xFF
minor := (versionRaw >> 20) & 0x0F
bugfix := (versionRaw >> 16) & 0x0F
p.Version = fmt.Sprintf("%d.%d.%d", major, minor, bugfix)
// Parse device class (bytes 12-15)
p.Class = string(data[12:16])
// Parse color space (bytes 16-19)
p.ColorSpace = string(data[16:20])
// Trim whitespace from color space
p.ColorSpace = trimASCII(p.ColorSpace)
// Parse tag table
if len(data) < 132 {
return nil, errors.New("icc: profile data too short for tag table")
}
tagCount := binary.BigEndian.Uint32(data[128:132])
// tagCount comes straight from the file, so compare it against the space
// that is actually left before sizing anything with it: tagCount*12 wraps
// in uint32 (0x15555556*12 == 8) and would otherwise let a 140-byte profile
// through the bounds check and request a ~4 GB tag table.
if uint64(tagCount) > uint64((len(data)-132)/12) {
return nil, fmt.Errorf("icc: profile data too short for %d tag entries", tagCount)
}
// Read tag entries
tags := make([]tagEntry, tagCount)
for i := uint32(0); i < tagCount; i++ {
off := uint32(132) + i*12
copy(tags[i].sig[:], data[off:off+4])
tags[i].offset = binary.BigEndian.Uint32(data[off+4 : off+8])
tags[i].size = binary.BigEndian.Uint32(data[off+8 : off+12])
}
// Build a map of tag signatures to entries
tagMap := make(map[[4]byte]tagEntry)
for _, t := range tags {
tagMap[t.sig] = t
}
// Parse description
if entry, ok := tagMap[sigDesc]; ok {
desc, err := parseDescription(data, entry)
if err == nil {
p.Description = desc
}
}
// Parse matrix columns (rXYZ, gXYZ, bXYZ)
rXYZ, hasR := tagMap[sigRXYZ]
gXYZ, hasG := tagMap[sigGXYZ]
bXYZ, hasB := tagMap[sigBXYZ]
if hasR && hasG && hasB {
rx, ry, rz, err := parseXYZType(data, rXYZ)
if err == nil {
gx, gy, gz, err := parseXYZType(data, gXYZ)
if err == nil {
bx, by, bz, err := parseXYZType(data, bXYZ)
if err == nil {
p.Matrix[0] = [3]float64{rx, ry, rz}
p.Matrix[1] = [3]float64{gx, gy, gz}
p.Matrix[2] = [3]float64{bx, by, bz}
p.HasMatrix = true
}
}
}
}
// Parse TRC curves (rTRC, gTRC, bTRC)
rTRC, hasRTRC := tagMap[sigRTRC]
gTRC, hasGTRC := tagMap[sigGTRC]
bTRC, hasBTRC := tagMap[sigBTRC]
if hasRTRC && hasGTRC && hasBTRC {
rc, err := parseCurveType(data, rTRC)
if err == nil {
gc, err := parseCurveType(data, gTRC)
if err == nil {
bc, err := parseCurveType(data, bTRC)
if err == nil {
p.TRC[0] = rc
p.TRC[1] = gc
p.TRC[2] = bc
p.HasTRC = true
}
}
}
}
// Parse vcgt
if entry, ok := tagMap[sigVCGT]; ok {
vcgt, err := parseVCGT(data, entry)
if err == nil {
p.VCGT = vcgt
p.HasVCGT = true
}
}
// Parse white point
if entry, ok := tagMap[sigWtpt]; ok {
x, y, z, err := parseXYZType(data, entry)
if err == nil {
p.WhitePoint = [3]float64{x, y, z}
}
}
return p, nil
}
// trimASCII trims spaces from a 4-byte ASCII field.
func trimASCII(s string) string {
i := 0
for i < len(s) && s[i] == ' ' {
i++
}
j := len(s)
for j > i && s[j-1] == ' ' {
j--
}
return s[i:j]
}
// readS15Fixed16 reads a signed 15.16 fixed-point number from big-endian bytes.
// Go's int32 cast handles two's complement correctly for negative values.
func readS15Fixed16(data []byte, offset int) float64 {
if offset+4 > len(data) {
return 0
}
raw := binary.BigEndian.Uint32(data[offset : offset+4])
signed := int32(raw)
return float64(signed) / 65536.0
}
// parseDescription parses the desc tag (textDescriptionType or mluc).
func parseDescription(data []byte, entry tagEntry) (string, error) {
if int(entry.offset)+int(entry.size) > len(data) {
return "", errors.New("icc: desc tag out of bounds")
}
if entry.size < 4 {
return "", errors.New("icc: desc tag too short")
}
sig := string(data[entry.offset : entry.offset+4])
switch sig {
case "desc":
// textDescriptionType: 'desc' + reserved(4) + stringLength(4) + ascii string
if entry.size < 12 {
return "", errors.New("icc: textDescriptionType too short")
}
strLen := binary.BigEndian.Uint32(data[entry.offset+8 : entry.offset+12])
if strLen == 0 {
return "", nil
}
// Compute in int so a large length cannot wrap past the buffer end.
strStart := int(entry.offset) + 12
strEnd := strStart + int(strLen)
if strEnd > len(data) {
strEnd = len(data)
}
if strStart < 0 || strStart > strEnd {
return "", errors.New("icc: textDescriptionType string out of bounds")
}
// strLen includes null terminator
s := data[strStart:strEnd]
// Trim null terminator
for i, b := range s {
if b == 0 {
s = s[:i]
break
}
}
return string(s), nil
case "mluc":
// mluc: 'mluc' + reserved(4) + numRecords(4) + recordSize(4) + records
if entry.size < 16 {
return "", errors.New("icc: mluc type too short")
}
numRecords := binary.BigEndian.Uint32(data[entry.offset+8 : entry.offset+12])
recordSize := binary.BigEndian.Uint32(data[entry.offset+12 : entry.offset+16])
if numRecords == 0 {
return "", nil
}
// Read first record
if recordSize < 12 {
return "", errors.New("icc: mluc record size too small")
}
recordStart := entry.offset + 16
if int(recordStart)+12 > len(data) {
return "", errors.New("icc: mluc record out of bounds")
}
strLen := binary.BigEndian.Uint32(data[recordStart+4 : recordStart+8])
strOff := binary.BigEndian.Uint32(data[recordStart+8 : recordStart+12])
// Decode UTF-16BE
if strLen < 2 {
return "", nil
}
// String offset is absolute from profile start. Compute in int so a
// large offset/length cannot wrap past the buffer end.
strStart := int(entry.offset) + int(strOff)
strEnd := strStart + int(strLen)
if strEnd > len(data) {
strEnd = len(data)
}
if strStart < 0 || strStart > strEnd {
return "", errors.New("icc: mluc string out of bounds")
}
return decodeUTF16BE(data[strStart:strEnd]), nil
default:
return "", fmt.Errorf("icc: unknown desc type signature: %q", sig)
}
}
// decodeUTF16BE decodes a big-endian UTF-16 byte slice to a Go string.
func decodeUTF16BE(data []byte) string {
if len(data) < 2 {
return ""
}
u16s := make([]uint16, 0, len(data)/2)
for i := 0; i+1 < len(data); i += 2 {
code := uint16(data[i])<<8 | uint16(data[i+1])
if code == 0 {
break
}
u16s = append(u16s, code)
}
return string(utf16.Decode(u16s))
}
// parseXYZType parses an XYZType tag (20 bytes: 'XYZ ' + reserved(4) + 3 × s15Fixed16).
func parseXYZType(data []byte, entry tagEntry) (float64, float64, float64, error) {
if int(entry.offset)+20 > len(data) {
return 0, 0, 0, errors.New("icc: XYZType tag out of bounds")
}
sig := string(data[entry.offset : entry.offset+4])
if sig != "XYZ " {
return 0, 0, 0, fmt.Errorf("icc: expected XYZ type signature, got %q", sig)
}
x := readS15Fixed16(data, int(entry.offset)+8)
y := readS15Fixed16(data, int(entry.offset)+12)
z := readS15Fixed16(data, int(entry.offset)+16)
return x, y, z, nil
}
// parseCurveType parses a curveType tag.
func parseCurveType(data []byte, entry tagEntry) (Curve, error) {
if int(entry.offset)+4 > len(data) {
return Curve{}, errors.New("icc: curveType tag out of bounds")
}
sig := string(data[entry.offset : entry.offset+4])
if sig != "curv" {
return Curve{}, fmt.Errorf("icc: expected curv type signature, got %q", sig)
}
if int(entry.offset)+12 > len(data) {
return Curve{}, errors.New("icc: curveType tag too short")
}
count := binary.BigEndian.Uint32(data[entry.offset+8 : entry.offset+12])
c := Curve{}
switch count {
case 0:
// Identity curve
c.Type = CurveIdentity
c.Gamma = 1.0
case 1:
// Single gamma value (u16Fixed16Number)
c.Type = CurveParametric
if int(entry.offset)+14 > len(data) {
return Curve{}, errors.New("icc: curveType too short for gamma value")
}
// Read 2 bytes as uint16, divide by 256.0
gammaRaw := binary.BigEndian.Uint16(data[entry.offset+12 : entry.offset+14])
c.Gamma = float64(gammaRaw) / 256.0
default:
// Table of uint16 entries
c.Type = CurveTable
needed := int(entry.offset) + 12 + int(count)*2
if needed > len(data) {
return Curve{}, fmt.Errorf("icc: curveType table out of bounds: need %d, have %d", needed, len(data))
}
c.Entries = make([]uint16, count)
for i := uint32(0); i < count; i++ {
off := int(entry.offset) + 12 + int(i)*2
c.Entries[i] = binary.BigEndian.Uint16(data[off : off+2])
}
}
return c, nil
}
// parseVCGT parses a videoCardGammaType tag.
func parseVCGT(data []byte, entry tagEntry) (*VCGT, error) {
if int(entry.offset)+int(entry.size) > len(data) {
return nil, errors.New("icc: vcgt tag out of bounds")
}
// Minimum header: 'vcgt'(4) + reserved(4) + tagType(4) + channels(2) + count(2) + entrySize(2) = 18
if entry.size < 18 {
return nil, errors.New("icc: vcgt tag too short")
}
sig := string(data[entry.offset : entry.offset+4])
if sig != "vcgt" {
return nil, fmt.Errorf("icc: expected vcgt signature, got %q", sig)
}
tagType := binary.BigEndian.Uint32(data[entry.offset+8 : entry.offset+12])
switch tagType {
case 0:
// Table type
channels := int(binary.BigEndian.Uint16(data[entry.offset+12 : entry.offset+14]))
count := int(binary.BigEndian.Uint16(data[entry.offset+14 : entry.offset+16]))
entrySize := int(binary.BigEndian.Uint16(data[entry.offset+16 : entry.offset+18]))
if count < 1 || entrySize < 1 {
return nil, fmt.Errorf("icc: vcgt invalid dimensions: channels=%d, count=%d, entrySize=%d", channels, count, entrySize)
}
if channels != 1 && channels != 3 {
return nil, fmt.Errorf("icc: vcgt has %d channels, want 1 or 3", channels)
}
dataStart := int(entry.offset) + 18
totalBytes := channels * count * entrySize
if dataStart+totalBytes > len(data) {
return nil, fmt.Errorf("icc: vcgt data out of bounds: need %d bytes, have %d", totalBytes, len(data)-dataStart)
}
vcgt := &VCGT{
Channels: channels,
Entries: count,
Red: make([]uint16, count),
Green: make([]uint16, count),
Blue: make([]uint16, count),
}
channels_arr := [3][]uint16{vcgt.Red, vcgt.Green, vcgt.Blue}
for ch := 0; ch < channels; ch++ {
for i := 0; i < count; i++ {
byteOff := dataStart + (ch*count+i)*entrySize
var val uint16
switch entrySize {
case 2:
val = binary.BigEndian.Uint16(data[byteOff : byteOff+2])
case 1:
// u8 entry: scale to u16
val = uint16(data[byteOff]) * 257 // 257 = 65535/255
default:
val = binary.BigEndian.Uint16(data[byteOff : byteOff+2])
}
channels_arr[ch][i] = val
}
}
if channels == 1 {
copy(vcgt.Green, vcgt.Red)
copy(vcgt.Blue, vcgt.Red)
}
return vcgt, nil
case 1:
// Formula type (rarely used, but parse it)
// 3 channels × (gamma u16Fixed16 + min s15Fixed16 + max s15Fixed16)
return nil, errors.New("icc: vcgt formula type not supported")
default:
return nil, fmt.Errorf("icc: vcgt unknown tag type: %d", tagType)
}
}
// SampleCurve evaluates a Curve at position t (0.0 to 1.0), returns 0.0 to 1.0.
func SampleCurve(curve Curve, t float64) float64 {
// Clamp t to [0, 1]
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
switch curve.Type {
case CurveIdentity:
return t
case CurveParametric:
if curve.Gamma == 0 {
return 0
}
return math.Pow(t, 1.0/curve.Gamma)
case CurveTable:
if len(curve.Entries) == 0 {
return t
}
if len(curve.Entries) == 1 {
return float64(curve.Entries[0]) / 65535.0
}
// t maps to index in [0, len-1]
idx := t * float64(len(curve.Entries)-1)
low := int(idx)
if low >= len(curve.Entries)-1 {
low = len(curve.Entries) - 2
}
if low < 0 {
low = 0
}
frac := idx - float64(low)
v0 := float64(curve.Entries[low])
v1 := float64(curve.Entries[low+1])
result := (v0 + frac*(v1-v0)) / 65535.0
if result < 0 {
result = 0
}
if result > 1 {
result = 1
}
return result
default:
return t
}
}
// GenerateGammaRamp creates a wlr-gamma-control compatible ramp from an ICC
// profile.
//
// Only vcgt carries a video card gamma table, which is what the GPU LUT takes.
// rTRC/gTRC/bTRC describe the device-to-PCS transfer function of the display
// itself (roughly t^2.2 for a normal monitor), not a correction to write into
// the ramp, so treating them as one would wash the display out instead of
// calibrating it. A profile without a vcgt table therefore has no ramp.
func GenerateGammaRamp(size uint32, profile *Profile) (GammaRamp, error) {
return GenerateGammaRampAt(size, profile, func(t float64) float64 { return t })
}
// GenerateGammaRampAt samples the vcgt table at input(t) for each ramp
// position t in [0, 1], so a caller can reshape the ramp input (contrast)
// before the table is applied.
func GenerateGammaRampAt(size uint32, profile *Profile, input func(float64) float64) (GammaRamp, error) {
if size < 2 {
return GammaRamp{}, errors.New("icc: ramp size must be at least 2")
}
if !profile.HasVCGT || profile.VCGT == nil {
return GammaRamp{}, errors.New("icc: profile has no vcgt table, so it has no video card gamma ramp")
}
ramp := GammaRamp{
Red: make([]uint16, size),
Green: make([]uint16, size),
Blue: make([]uint16, size),
}
vcgt := profile.VCGT
for i := uint32(0); i < size; i++ {
t := input(float64(i) / float64(size-1))
ramp.Red[i] = resampleVCGT(vcgt.Red, t)
ramp.Green[i] = resampleVCGT(vcgt.Green, t)
ramp.Blue[i] = resampleVCGT(vcgt.Blue, t)
}
return ramp, nil
}
// resampleVCGT resamples a vcgt channel at position t (0.0 to 1.0) using linear interpolation.
func resampleVCGT(entries []uint16, t float64) uint16 {
if len(entries) == 0 {
return uint16Clamp(t * 65535.0)
}
if len(entries) == 1 {
return entries[0]
}
idx := t * float64(len(entries)-1)
low := int(idx)
if low >= len(entries)-1 {
low = len(entries) - 2
}
if low < 0 {
low = 0
}
frac := idx - float64(low)
v := float64(entries[low]) + frac*(float64(entries[low+1])-float64(entries[low]))
return uint16Clamp(v)
}
// uint16Clamp clamps a float64 to [0, 65535] and converts to uint16.
func uint16Clamp(v float64) uint16 {
if v < 0 {
return 0
}
if v > 65535 {
return 65535
}
return uint16(v + 0.5) // round to nearest
}
// WhitePointXY returns the chromaticity coordinates of the profile white point.
func (p *Profile) WhitePointXY() (float64, float64, bool) {
sum := p.WhitePoint[0] + p.WhitePoint[1] + p.WhitePoint[2]
if sum <= 0 {
return 0, 0, false
}
return p.WhitePoint[0] / sum, p.WhitePoint[1] / sum, true
}
// WhitePointCCT estimates the correlated color temperature of the profile white
// point in kelvin using McCamy's approximation. It returns 0 when the white
// point is missing or the estimate is out of range.
func (p *Profile) WhitePointCCT() int {
x, y, ok := p.WhitePointXY()
if !ok {
return 0
}
denominator := 0.1858 - y
if math.Abs(denominator) < 1e-9 {
return 0
}
n := (x - 0.3320) / denominator
cct := 449.0*n*n*n + 3525.0*n*n + 6823.3*n + 5520.33
if cct < 1000 || cct > 40000 {
return 0
}
return int(math.Round(cct))
}
// WhitePointName names the white point when it is close to a standard
// illuminant ("D50", "D65"), or returns an empty string.
func (p *Profile) WhitePointName() string {
cct := p.WhitePointCCT()
switch {
case cct == 0:
return ""
case math.Abs(float64(cct)-6504) <= 150:
return "D65"
case math.Abs(float64(cct)-5003) <= 150:
return "D50"
default:
return ""
}
}
// TRCKind describes how the tone reproduction curves are stored: "identity",
// "gamma", "table" or "mixed". gamma and entries are only set when all three
// channels agree.
func (p *Profile) TRCKind() (kind string, gamma float64, entries int) {
if !p.HasTRC {
return "", 0, 0
}
identity, parametric, tables := 0, 0, 0
sameGamma := true
sameEntries := true
for i, curve := range p.TRC {
switch curve.Type {
case CurveIdentity:
identity++
case CurveParametric:
parametric++
case CurveTable:
tables++
}
if i == 0 {
gamma = curve.Gamma
entries = len(curve.Entries)
continue
}
if curve.Gamma != gamma {
sameGamma = false
}
if len(curve.Entries) != entries {
sameEntries = false
}
}
switch {
case identity == len(p.TRC):
return "identity", 0, 0
case parametric == len(p.TRC) && sameGamma:
return "gamma", gamma, 0
case tables == len(p.TRC) && sameEntries:
return "table", 0, entries
default:
return "mixed", 0, 0
}
}
+663
View File
@@ -0,0 +1,663 @@
package icc
import (
"encoding/binary"
"fmt"
"math"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// Vendor-profile tests are optional: set ICC_TEST_DIR to a directory holding
// the files below to run them against real display profiles. CI has none, so
// they are skipped there and the synthetic tests at the bottom cover parsing
// and ramp generation instead.
var testFiles = []struct {
name string
file string
}{
{
name: "Samsung Odyssey Neo G8 (v2.1, matrix-TRC, vcgt)",
file: "1D8HG_B173ZAN_31-08-2023.icm",
},
{
name: "DisplayPort monitor (v2.1, matrix-TRC, vcgt)",
file: "DP_31-08-2023.icm",
},
{
name: "GPD Win Max 2 (v2.2, XYZLUT+MTX, vcgt)",
file: "GPD1001H #1 2024-11-29 22-12 D6500 2.2 F-S XYZLUT+MTX.icm",
},
}
// vendorProfile resolves an optional vendor profile, skipping the test when
// ICC_TEST_DIR is unset or the file is missing.
func vendorProfile(t *testing.T, file string) string {
t.Helper()
dir := os.Getenv("ICC_TEST_DIR")
if dir == "" {
t.Skip("ICC_TEST_DIR not set; skipping vendor profile test")
}
path := filepath.Join(dir, file)
if _, err := os.Stat(path); err != nil {
t.Skipf("vendor profile unavailable: %v", err)
}
return path
}
func TestParseFile(t *testing.T) {
for _, tc := range testFiles {
t.Run(tc.name, func(t *testing.T) {
path := vendorProfile(t, tc.file)
// Read file to get expected size
info, err := os.Stat(path)
if err != nil {
t.Fatalf("cannot stat file: %v", err)
}
p, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile failed: %v", err)
}
// Verify size matches file size
if p.Size != uint32(info.Size()) {
t.Errorf("Size: got %d, want %d", p.Size, info.Size())
}
// Verify version
if p.Version != "2.1.0" && p.Version != "2.2.0" && p.Version != "2.4.0" {
t.Errorf("Version: got %q, want v2.x", p.Version)
}
t.Logf(" Version: %s", p.Version)
// Verify class
if p.Class != "mntr" {
t.Errorf("Class: got %q, want %q", p.Class, "mntr")
}
// Verify color space
if p.ColorSpace != "RGB" {
t.Errorf("ColorSpace: got %q, want %q", p.ColorSpace, "RGB")
}
// Verify description is non-empty
if p.Description == "" {
t.Error("Description is empty")
}
t.Logf(" Description: %s", p.Description)
// Verify matrix
if !p.HasMatrix {
t.Error("HasMatrix is false, expected true")
} else {
t.Logf(" Matrix:")
for i := 0; i < 3; i++ {
t.Logf(" [%6.4f %6.4f %6.4f]", p.Matrix[0][i], p.Matrix[1][i], p.Matrix[2][i])
}
// Each Y value (second row of matrix) should be in [0, 1]
for _, col := range p.Matrix {
if col[1] < -0.01 || col[1] > 1.5 {
t.Errorf("Matrix Y value out of range: %f", col[1])
}
}
}
// Verify VCGT
if !p.HasVCGT {
t.Error("HasVCGT is false, expected true")
} else {
t.Logf(" VCGT: %d channels, %d entries", p.VCGT.Channels, p.VCGT.Entries)
t.Logf(" VCGT Red: [%d ... %d]", p.VCGT.Red[0], p.VCGT.Red[len(p.VCGT.Red)-1])
// Verify entry count matches declared
if len(p.VCGT.Red) != p.VCGT.Entries {
t.Errorf("VCGT Red entries: got %d, want %d", len(p.VCGT.Red), p.VCGT.Entries)
}
if len(p.VCGT.Green) != p.VCGT.Entries {
t.Errorf("VCGT Green entries: got %d, want %d", len(p.VCGT.Green), p.VCGT.Entries)
}
if len(p.VCGT.Blue) != p.VCGT.Entries {
t.Errorf("VCGT Blue entries: got %d, want %d", len(p.VCGT.Blue), p.VCGT.Entries)
}
// Verify channels
if p.VCGT.Channels != 3 {
t.Errorf("VCGT channels: got %d, want 3", p.VCGT.Channels)
}
// Verify monotonic non-decreasing per channel
for _, ch := range []struct {
name string
data []uint16
}{{"Red", p.VCGT.Red}, {"Green", p.VCGT.Green}, {"Blue", p.VCGT.Blue}} {
for i := 1; i < len(ch.data); i++ {
if ch.data[i] < ch.data[i-1] {
t.Errorf("VCGT %s not monotonic at index %d: %d < %d", ch.name, i, ch.data[i], ch.data[i-1])
break
}
}
}
}
// Log white point
t.Logf(" White point: [%6.4f %6.4f %6.4f]", p.WhitePoint[0], p.WhitePoint[1], p.WhitePoint[2])
})
}
}
func TestGenerateGammaRamp(t *testing.T) {
for _, tc := range testFiles {
t.Run(tc.name, func(t *testing.T) {
p, err := ParseFile(vendorProfile(t, tc.file))
if err != nil {
t.Fatalf("ParseFile failed: %v", err)
}
if !p.HasVCGT {
if _, err := GenerateGammaRamp(256, p); err == nil {
t.Error("expected an error for a profile without a vcgt table")
}
t.Skip("profile carries no vcgt table, so it has no ramp")
}
for _, rampSize := range []uint32{256, 4096} {
t.Run("size="+itoa(rampSize), func(t *testing.T) {
ramp, err := GenerateGammaRamp(rampSize, p)
if err != nil {
t.Fatalf("GenerateGammaRamp failed: %v", err)
}
// Verify lengths
if uint32(len(ramp.Red)) != rampSize {
t.Errorf("Red length: got %d, want %d", len(ramp.Red), rampSize)
}
if uint32(len(ramp.Green)) != rampSize {
t.Errorf("Green length: got %d, want %d", len(ramp.Green), rampSize)
}
if uint32(len(ramp.Blue)) != rampSize {
t.Errorf("Blue length: got %d, want %d", len(ramp.Blue), rampSize)
}
// Verify range [0, 65535]
channels := []struct {
name string
data []uint16
}{
{"Red", ramp.Red},
{"Green", ramp.Green},
{"Blue", ramp.Blue},
}
for _, ch := range channels {
// All values must be in [0, 65535] (uint16 guarantees this, but double-check)
for i, v := range ch.data {
if v > 65535 {
t.Errorf("%s[%d] = %d, out of range", ch.name, i, v)
break
}
}
// Verify ramp values match VCGT-derived data.
// Real VCGT calibration data does NOT guarantee ramp[0]==0
// or ramp[last]==65535 — these are gamma corrections.
// Log endpoints for inspection.
t.Logf(" %s: [%d ... %d]", ch.name, ch.data[0], ch.data[rampSize-1])
// Verify monotonic non-decreasing
for i := 1; i < len(ch.data); i++ {
if ch.data[i] < ch.data[i-1] {
t.Errorf("%s not monotonic at index %d: %d < %d", ch.name, i, ch.data[i], ch.data[i-1])
break
}
}
// ramp[last] >= ramp[0] (endpoints must be ordered)
if ch.data[rampSize-1] < ch.data[0] {
t.Errorf("%s: last value %d < first value %d", ch.name, ch.data[rampSize-1], ch.data[0])
}
}
})
}
})
}
}
func TestSampleCurve(t *testing.T) {
t.Run("Identity", func(t *testing.T) {
c := Curve{Type: CurveIdentity}
for _, tt := range []float64{0, 0.25, 0.5, 0.75, 1.0} {
got := SampleCurve(c, tt)
if got != tt {
t.Errorf("SampleCurve(identity, %f) = %f, want %f", tt, got, tt)
}
}
})
t.Run("Parametric", func(t *testing.T) {
c := Curve{Type: CurveParametric, Gamma: 2.2}
// pow(0.5, 1/2.2) ≈ 0.7297
got := SampleCurve(c, 0.5)
want := math.Pow(0.5, 1.0/2.2)
if math.Abs(got-want) > 0.001 {
t.Errorf("SampleCurve(gamma2.2, 0.5) = %f, want %f", got, want)
}
// Boundary conditions
if v := SampleCurve(c, 0); v != 0 {
t.Errorf("SampleCurve(gamma2.2, 0) = %f, want 0", v)
}
if v := SampleCurve(c, 1); v != 1 {
t.Errorf("SampleCurve(gamma2.2, 1) = %f, want 1", v)
}
})
t.Run("Table", func(t *testing.T) {
// Identity table: 2 entries [0, 65535]
c := Curve{
Type: CurveTable,
Entries: []uint16{0, 65535},
}
if v := SampleCurve(c, 0.5); math.Abs(v-0.5) > 0.001 {
t.Errorf("SampleCurve(identityTable, 0.5) = %f, want ~0.5", v)
}
// 4-entry table: [0, 21845, 43690, 65535] (approx 1/3, 2/3, 3/3)
c2 := Curve{
Type: CurveTable,
Entries: []uint16{0, 21845, 43690, 65535},
}
if v := SampleCurve(c2, 0); v != 0 {
t.Errorf("SampleCurve(4entry, 0) = %f, want 0", v)
}
if v := SampleCurve(c2, 1.0); math.Abs(v-1.0) > 0.001 {
t.Errorf("SampleCurve(4entry, 1.0) = %f, want ~1.0", v)
}
// 256-entry identity table
entries256 := make([]uint16, 256)
for i := range entries256 {
entries256[i] = uint16(i * 65535 / 255)
}
c3 := Curve{Type: CurveTable, Entries: entries256}
if v := SampleCurve(c3, 0.5); math.Abs(v-0.5) > 0.01 {
t.Errorf("SampleCurve(256entry, 0.5) = %f, want ~0.5", v)
}
})
t.Run("ClampInput", func(t *testing.T) {
c := Curve{Type: CurveIdentity}
// Negative t should be clamped to 0
if v := SampleCurve(c, -0.5); v != 0 {
t.Errorf("SampleCurve(identity, -0.5) = %f, want 0", v)
}
// t > 1 should be clamped to 1
if v := SampleCurve(c, 1.5); v != 1 {
t.Errorf("SampleCurve(identity, 1.5) = %f, want 1", v)
}
})
}
func TestParseBytes(t *testing.T) {
t.Run("TooShort", func(t *testing.T) {
_, err := ParseBytes([]byte{0, 1, 2})
if err == nil {
t.Error("expected error for short data")
}
})
}
// itoa converts a uint32 to string (avoids importing strconv)
func itoa(n uint32) string {
if n == 0 {
return "0"
}
digits := ""
for n > 0 {
digits = string(rune('0'+n%10)) + digits
n /= 10
}
return digits
}
// --- synthetic profile tests (self-contained; also cover CI) ---
func putS15Fixed16(b []byte, off int, v float64) {
binary.BigEndian.PutUint32(b[off:], uint32(int32(math.Round(v*65536))))
}
func putU8Fixed8(b []byte, off int, v float64) {
binary.BigEndian.PutUint16(b[off:], uint16(math.Round(v*256)))
}
// buildSyntheticProfile builds a minimal but valid ICC v2 monitor profile with
// desc, rXYZ/gXYZ/bXYZ, rTRC/gTRC/bTRC (single gamma per channel), wtpt and a
// table vcgt tag, so parsing and ramp generation can be exercised without a
// vendor profile on disk.
func buildSyntheticProfile(desc string, gamma float64) []byte {
xyzTag := func(x, y, z float64) []byte {
d := make([]byte, 20)
copy(d[0:], "XYZ ")
putS15Fixed16(d, 8, x)
putS15Fixed16(d, 12, y)
putS15Fixed16(d, 16, z)
return d
}
curvTag := func(g float64) []byte {
d := make([]byte, 16)
copy(d[0:], "curv")
binary.BigEndian.PutUint32(d[8:], 1) // count=1 -> single gamma value
putU8Fixed8(d, 12, g)
return d
}
descTag := make([]byte, 12+len(desc)+1)
copy(descTag[0:], "desc")
binary.BigEndian.PutUint32(descTag[8:], uint32(len(desc)+1)) // length includes NUL
copy(descTag[12:], desc)
const vcgtEntries = 4
vcgtTag := make([]byte, 18+3*vcgtEntries*2)
copy(vcgtTag[0:], "vcgt")
binary.BigEndian.PutUint16(vcgtTag[12:], 3) // channels
binary.BigEndian.PutUint16(vcgtTag[14:], vcgtEntries) // entries per channel
binary.BigEndian.PutUint16(vcgtTag[16:], 2) // entry size in bytes
for ch := 0; ch < 3; ch++ {
for i := 0; i < vcgtEntries; i++ {
v := uint16(math.Round(65535.0 * float64(i) / float64(vcgtEntries-1)))
binary.BigEndian.PutUint16(vcgtTag[18+(ch*vcgtEntries+i)*2:], v)
}
}
type tag struct {
sig string
data []byte
}
tags := []tag{
{"desc", descTag},
{"rXYZ", xyzTag(0.4360, 0.2225, 0.0139)},
{"gXYZ", xyzTag(0.3851, 0.7169, 0.0971)},
{"bXYZ", xyzTag(0.1431, 0.0606, 0.7141)},
{"rTRC", curvTag(gamma)},
{"gTRC", curvTag(gamma)},
{"bTRC", curvTag(gamma)},
{"wtpt", xyzTag(0.9642, 1.0, 0.8249)},
{"vcgt", vcgtTag},
}
const headerSize = 128
tableSize := 4 + 12*len(tags)
total := headerSize + tableSize
for _, tg := range tags {
total += len(tg.data)
}
buf := make([]byte, total)
binary.BigEndian.PutUint32(buf[0:], uint32(total))
binary.BigEndian.PutUint32(buf[8:], 0x02100000) // version 2.1.0
copy(buf[12:], "mntr")
copy(buf[16:], "RGB ")
copy(buf[20:], "XYZ ")
binary.BigEndian.PutUint16(buf[24:], 2026)
binary.BigEndian.PutUint16(buf[26:], 9)
binary.BigEndian.PutUint16(buf[28:], 10)
copy(buf[36:], "acsp")
putS15Fixed16(buf, 68, 0.9642) // D50 illuminant
putS15Fixed16(buf, 72, 1.0)
putS15Fixed16(buf, 76, 0.8249)
binary.BigEndian.PutUint32(buf[128:], uint32(len(tags)))
off := headerSize + tableSize
for i, tg := range tags {
base := 132 + i*12
copy(buf[base:], tg.sig)
binary.BigEndian.PutUint32(buf[base+4:], uint32(off))
binary.BigEndian.PutUint32(buf[base+8:], uint32(len(tg.data)))
copy(buf[off:], tg.data)
off += len(tg.data)
}
return buf
}
func TestParseBytesSynthetic(t *testing.T) {
const desc = "Synthetic Test Display"
data := buildSyntheticProfile(desc, 2.2)
p, err := ParseBytes(data)
if err != nil {
t.Fatalf("ParseBytes failed: %v", err)
}
if p.Size != uint32(len(data)) {
t.Errorf("Size = %d, want %d", p.Size, len(data))
}
if p.Version != "2.1.0" {
t.Errorf("Version = %q, want %q", p.Version, "2.1.0")
}
if p.Class != "mntr" {
t.Errorf("Class = %q, want %q", p.Class, "mntr")
}
if p.ColorSpace != "RGB" {
t.Errorf("ColorSpace = %q, want %q", p.ColorSpace, "RGB")
}
if p.Description != desc {
t.Errorf("Description = %q, want %q", p.Description, desc)
}
if !p.HasMatrix {
t.Error("HasMatrix = false, want true")
}
if !p.HasTRC {
t.Error("HasTRC = false, want true")
}
if !p.HasVCGT {
t.Error("HasVCGT = false, want true")
}
if got := p.TRC[0].Gamma; math.Abs(got-2.2) > 0.01 {
t.Errorf("TRC[0].Gamma = %f, want ~2.2", got)
}
if got := p.WhitePoint[0]; math.Abs(got-0.9642) > 0.001 {
t.Errorf("WhitePoint X = %f, want ~0.9642", got)
}
}
func TestGenerateGammaRampSynthetic(t *testing.T) {
p, err := ParseBytes(buildSyntheticProfile("Synthetic Test Display", 2.2))
if err != nil {
t.Fatalf("ParseBytes failed: %v", err)
}
const size = 256
ramp, err := GenerateGammaRamp(size, p)
if err != nil {
t.Fatalf("GenerateGammaRamp failed: %v", err)
}
if len(ramp.Red) != size || len(ramp.Green) != size || len(ramp.Blue) != size {
t.Fatalf("ramp lengths = %d/%d/%d, want %d", len(ramp.Red), len(ramp.Green), len(ramp.Blue), size)
}
if ramp.Red[size-1] <= ramp.Red[0] {
t.Errorf("ramp not increasing: first=%d last=%d", ramp.Red[0], ramp.Red[size-1])
}
}
// A profile without a vcgt table has no video card gamma ramp: the TRC tags
// describe the display's own transfer function, so writing them into the GPU
// LUT washes the output out instead of calibrating it.
func TestGenerateGammaRampRequiresVCGT(t *testing.T) {
trcOnly := &Profile{
HasTRC: true,
HasVCGT: false,
TRC: [3]Curve{
{Type: CurveParametric, Gamma: 2.2},
{Type: CurveParametric, Gamma: 2.2},
{Type: CurveParametric, Gamma: 2.2},
},
}
if _, err := GenerateGammaRamp(256, trcOnly); err == nil {
t.Fatal("expected an error for a profile without a vcgt table")
}
// A vcgt tag with no channel data is unusable in the same way.
emptyVCGT := &Profile{HasVCGT: true}
if _, err := GenerateGammaRamp(256, emptyVCGT); err == nil {
t.Fatal("expected an error for a profile without a vcgt table")
}
// The ramp size has to be at least 2: the values are spread over size-1.
if _, err := GenerateGammaRamp(1, &Profile{HasVCGT: true, VCGT: &VCGT{Channels: 3, Entries: 4}}); err == nil {
t.Fatal("expected an error for a ramp size below 2")
}
}
// TestParseDescriptionMalformed feeds desc/mluc tags whose offsets or lengths
// point outside the buffer. These must return an error or a clamped string
// instead of a slice-bounds panic: `dms icc list` parses every file in the
// profile directory and the daemon parses user-picked files in-process, so a
// panic here takes the whole daemon down.
func TestParseDescriptionMalformed(t *testing.T) {
descSig := [4]byte{'d', 'e', 's', 'c'}
mlucSig := [4]byte{'m', 'l', 'u', 'c'}
t.Run("desc length wraps past end", func(t *testing.T) {
data := make([]byte, 32)
copy(data[0:], "desc")
// 12 + 0xFFFFFFF8 wraps to 4 in uint32 arithmetic.
binary.BigEndian.PutUint32(data[8:], 0xFFFFFFF8)
if _, err := parseDescription(data, tagEntry{sig: descSig, offset: 0, size: 32}); err != nil {
t.Fatalf("expected clamped string, got error %v", err)
}
})
t.Run("desc length past end", func(t *testing.T) {
data := make([]byte, 24)
copy(data[0:], "desc")
binary.BigEndian.PutUint32(data[8:], 4096)
desc, err := parseDescription(data, tagEntry{sig: descSig, offset: 0, size: 24})
if err != nil {
t.Fatalf("expected clamped string, got error %v", err)
}
if desc != "" {
t.Errorf("expected empty description, got %q", desc)
}
})
t.Run("mluc record header past end", func(t *testing.T) {
data := make([]byte, 16)
copy(data[0:], "mluc")
binary.BigEndian.PutUint32(data[8:], 1) // numRecords
binary.BigEndian.PutUint32(data[12:], 12) // recordSize
if _, err := parseDescription(data, tagEntry{sig: mlucSig, offset: 0, size: 16}); err == nil {
t.Fatal("expected error for truncated mluc record header")
}
})
t.Run("mluc string offset past end", func(t *testing.T) {
data := make([]byte, 32)
copy(data[0:], "mluc")
binary.BigEndian.PutUint32(data[8:], 1) // numRecords
binary.BigEndian.PutUint32(data[12:], 12) // recordSize
binary.BigEndian.PutUint32(data[20:], 4) // string length
binary.BigEndian.PutUint32(data[24:], 0xFFFFFF00)
if _, err := parseDescription(data, tagEntry{sig: mlucSig, offset: 0, size: 32}); err == nil {
t.Fatal("expected error for out-of-bounds mluc string")
}
})
}
// TestParseBytesTagTableBounds feeds tag counts that do not fit in the file.
// The count sizes a slice before any entry is read, so a wrapping count has to
// be rejected up front: with tagCount*12 wrapping in uint32, a 140-byte profile
// passed the old bounds check, allocated a ~4 GB tag slice and then panicked
// with slice bounds out of range. ParseFile runs on the wayland actor goroutine,
// which has no recover(), so that panic took the dms daemon down at startup.
func TestParseBytesTagTableBounds(t *testing.T) {
t.Run("count wraps in uint32 arithmetic", func(t *testing.T) {
data := buildSyntheticProfile("Wrapping tag count", 2.2)
binary.BigEndian.PutUint32(data[128:132], 0x15555556) // *12 == 8
_, err := ParseBytes(data)
if err == nil {
t.Fatal("expected error for a tag count that does not fit in the profile")
}
if !strings.Contains(err.Error(), "tag entries") {
t.Fatalf("ParseBytes() error = %v, want a tag entry count error", err)
}
})
t.Run("count exceeds remaining bytes", func(t *testing.T) {
data := buildSyntheticProfile("Too many tags", 2.2)
binary.BigEndian.PutUint32(data[128:132], uint32((len(data)-132)/12+1))
_, err := ParseBytes(data)
if err == nil {
t.Fatal("expected error for more tag entries than the profile can hold")
}
if !strings.Contains(err.Error(), "tag entries") {
t.Fatalf("ParseBytes() error = %v, want a tag entry count error", err)
}
})
t.Run("count filling the file exactly still parses", func(t *testing.T) {
data := buildSyntheticProfile("Exact fit", 2.2)
binary.BigEndian.PutUint32(data[128:132], uint32((len(data)-132)/12))
if _, err := ParseBytes(data); err != nil {
t.Fatalf("ParseBytes() = %v, want the boundary count accepted", err)
}
})
}
func vcgtTableTag(channels int, entries []uint16) []byte {
tag := make([]byte, 18+channels*len(entries)*2)
copy(tag[0:], "vcgt")
binary.BigEndian.PutUint16(tag[12:], uint16(channels))
binary.BigEndian.PutUint16(tag[14:], uint16(len(entries)))
binary.BigEndian.PutUint16(tag[16:], 2)
for ch := 0; ch < channels; ch++ {
for i, v := range entries {
binary.BigEndian.PutUint16(tag[18+(ch*len(entries)+i)*2:], v)
}
}
return tag
}
func TestParseVCGTChannelCount(t *testing.T) {
entries := []uint16{0, 21845, 43690, 65535}
t.Run("one channel is replicated to all three", func(t *testing.T) {
tag := vcgtTableTag(1, entries)
vcgt, err := parseVCGT(tag, tagEntry{offset: 0, size: uint32(len(tag))})
if err != nil {
t.Fatalf("parseVCGT: %v", err)
}
if !slices.Equal(vcgt.Red, entries) {
t.Fatalf("red = %v, want %v", vcgt.Red, entries)
}
if !slices.Equal(vcgt.Green, entries) || !slices.Equal(vcgt.Blue, entries) {
t.Fatalf("green/blue must copy the single curve: green=%v blue=%v", vcgt.Green, vcgt.Blue)
}
})
t.Run("three channels are read independently", func(t *testing.T) {
tag := vcgtTableTag(3, entries)
vcgt, err := parseVCGT(tag, tagEntry{offset: 0, size: uint32(len(tag))})
if err != nil {
t.Fatalf("parseVCGT: %v", err)
}
if !slices.Equal(vcgt.Blue, entries) {
t.Fatalf("blue = %v, want %v", vcgt.Blue, entries)
}
})
for _, channels := range []int{0, 2, 4} {
t.Run(fmt.Sprintf("%d channels are rejected", channels), func(t *testing.T) {
tag := vcgtTableTag(channels, entries)
if _, err := parseVCGT(tag, tagEntry{offset: 0, size: uint32(len(tag))}); err == nil {
t.Fatalf("expected an error for a %d-channel vcgt", channels)
}
})
}
}
+1 -1
View File
@@ -144,7 +144,7 @@ func InitializeWaylandManager() error {
wlContext = ctx
}
config := wayland.DefaultConfig()
config := wayland.LoadConfig()
manager, err := wayland.NewManager(wlContext.Display(), config)
if err != nil {
log.Errorf("Failed to initialize wayland manager: %v", err)
+51
View File
@@ -2,6 +2,8 @@ package wayland
import (
"math"
"github.com/AvengeMedia/DankMaterialShell/core/internal/icc"
)
type GammaRamp struct {
@@ -166,3 +168,52 @@ func GenerateIdentityRamp(size uint32) GammaRamp {
return ramp
}
// ProfileRampWithTemp builds the ramp for an output with an ICC profile: the
// vcgt table is sampled at the contrast-adjusted input, the user gamma is
// applied to the result, and a temperature target (targetTemp > 0) is composed
// as the white point ratio against baseTemp, the profile's reference white.
func ProfileRampWithTemp(size uint32, profile *icc.Profile, baseTemp, targetTemp int, gamma, contrast float64) (GammaRamp, error) {
iccRamp, err := icc.GenerateGammaRampAt(size, profile, func(t float64) float64 { return applyContrast(t, contrast) })
if err != nil {
return GammaRamp{}, err
}
tint := whitepointRatio(baseTemp, targetTemp)
ramp := GammaRamp{
Red: make([]uint16, size),
Green: make([]uint16, size),
Blue: make([]uint16, size),
}
for i := range size {
ramp.Red[i] = adjustRampValue(iccRamp.Red[i], tint.r, gamma)
ramp.Green[i] = adjustRampValue(iccRamp.Green[i], tint.g, gamma)
ramp.Blue[i] = adjustRampValue(iccRamp.Blue[i], tint.b, gamma)
}
return ramp, nil
}
func whitepointRatio(baseTemp, targetTemp int) rgb {
if targetTemp <= 0 || targetTemp == baseTemp {
return rgb{r: 1, g: 1, b: 1}
}
base := calcWhitepoint(baseTemp)
target := calcWhitepoint(targetTemp)
return rgb{
r: channelRatio(target.r, base.r),
g: channelRatio(target.g, base.g),
b: channelRatio(target.b, base.b),
}
}
func channelRatio(target, base float64) float64 {
if base <= 0 {
return 1
}
return target / base
}
func adjustRampValue(value uint16, tint, gamma float64) uint16 {
linear := clamp01(float64(value) / 65535.0 * tint)
return uint16(math.Round(clamp01(math.Pow(linear, 1.0/gamma)) * 65535.0))
}
+166
View File
@@ -2,8 +2,10 @@ package wayland
import (
"math"
"slices"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/icc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
@@ -156,3 +158,167 @@ func TestGenerateGammaRamp_ContrastPivotsAtMidGray(t *testing.T) {
t.Errorf("contrast 0.5 should compress endpoints to quarter points, got %d and %d", low.Red[0], low.Red[size-1])
}
}
// Profile ramps are composed with a target temperature relative to the white
// point display profiles are produced at (D65): no target keeps the profile as
// measured, a target shifts it.
func TestProfileRampWithTemp(t *testing.T) {
const size = uint32(256)
mid := int(size) / 2
// Only vcgt carries a video card gamma table, so the fixture is a profile
// whose table is the identity ramp: that keeps the expectations below about
// the temperature composition readable.
identityTable := func(entries int) []uint16 {
out := make([]uint16, entries)
for i := range out {
out[i] = uint16(float64(i) / float64(entries-1) * 65535.0)
}
return out
}
profile := &icc.Profile{
HasVCGT: true,
VCGT: &icc.VCGT{
Channels: 3,
Entries: 256,
Red: identityTable(256),
Green: identityTable(256),
Blue: identityTable(256),
},
}
identity := GenerateIdentityRamp(size)
t.Run("no target keeps the profile as measured", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, noTempTarget, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
if !slices.Equal(identity.Red, ramp.Red) || !slices.Equal(identity.Blue, ramp.Blue) {
t.Fatal("profile ramp should be unchanged without a temperature target")
}
})
t.Run("target equal to the reference leaves the profile alone", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, neutralTemp, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
if !slices.Equal(identity.Red, ramp.Red) {
t.Fatal("profile ramp should be unchanged at its reference white point")
}
})
t.Run("cooler target cools the output", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, 7000, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
if ramp.Red[mid] >= identity.Red[mid] {
t.Errorf("red should be pulled down: got %d, want < %d", ramp.Red[mid], identity.Red[mid])
}
if ramp.Green[mid] >= identity.Green[mid] {
t.Errorf("green should be pulled down: got %d, want < %d", ramp.Green[mid], identity.Green[mid])
}
if ramp.Blue[mid] != identity.Blue[mid] {
t.Errorf("blue is the reference channel and should be unchanged: got %d, want %d", ramp.Blue[mid], identity.Blue[mid])
}
})
t.Run("warmer target warms the output", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, 5000, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
if ramp.Red[mid] != identity.Red[mid] {
t.Errorf("red is the reference channel and should be unchanged: got %d, want %d", ramp.Red[mid], identity.Red[mid])
}
if ramp.Green[mid] >= identity.Green[mid] {
t.Errorf("green should be pulled down: got %d, want < %d", ramp.Green[mid], identity.Green[mid])
}
if ramp.Blue[mid] >= identity.Blue[mid] {
t.Errorf("blue should be pulled down: got %d, want < %d", ramp.Blue[mid], identity.Blue[mid])
}
})
}
func identityVCGTProfile(entries int) *icc.Profile {
table := func() []uint16 {
out := make([]uint16, entries)
for i := range out {
out[i] = uint16(float64(i) / float64(entries-1) * 65535.0)
}
return out
}
return &icc.Profile{
HasVCGT: true,
VCGT: &icc.VCGT{Channels: 3, Entries: entries, Red: table(), Green: table(), Blue: table()},
}
}
func TestProfileRampWithTempAppliesGammaAndContrast(t *testing.T) {
const size = uint32(256)
const tolerance = 2
profile := identityVCGTProfile(256)
closeTo := func(t *testing.T, name string, got, want []uint16) {
t.Helper()
for i := range want {
diff := int(got[i]) - int(want[i])
if diff < -tolerance || diff > tolerance {
t.Fatalf("%s[%d] = %d, want %d (±%d)", name, i, got[i], want[i], tolerance)
}
}
}
cases := []struct {
name string
target int
gamma float64
contrast float64
}{
{"no target, gamma only", noTempTarget, 1.8, 1.0},
{"no target, contrast only", noTempTarget, 1.0, 1.4},
{"reference target, both", neutralTemp, 0.8, 0.7},
{"warm target, both", 4000, 1.5, 1.3},
{"cool target, both", 8000, 0.9, 0.6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, tc.target, tc.gamma, tc.contrast)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
plainTemp := tc.target
if plainTemp <= 0 {
plainTemp = neutralTemp
}
want := GenerateGammaRamp(size, plainTemp, tc.gamma, tc.contrast)
closeTo(t, "red", ramp.Red, want.Red)
closeTo(t, "green", ramp.Green, want.Green)
closeTo(t, "blue", ramp.Blue, want.Blue)
})
}
t.Run("gamma changes the profile ramp without a target", func(t *testing.T) {
flat, _ := ProfileRampWithTemp(size, profile, neutralTemp, noTempTarget, 1.0, 1.0)
raised, _ := ProfileRampWithTemp(size, profile, neutralTemp, noTempTarget, 2.0, 1.0)
mid := int(size) / 2
if raised.Red[mid] <= flat.Red[mid] {
t.Fatalf("gamma 2.0 should lift mid gray: got %d, flat %d", raised.Red[mid], flat.Red[mid])
}
})
t.Run("contrast pivots the profile ramp at mid gray", func(t *testing.T) {
const oddSize = uint32(257)
flat, _ := ProfileRampWithTemp(oddSize, profile, neutralTemp, noTempTarget, 1.0, 1.0)
high, _ := ProfileRampWithTemp(oddSize, profile, neutralTemp, noTempTarget, 1.0, 2.0)
mid := int(oddSize) / 2
quarter := int(oddSize) / 4
if diff := int(high.Green[mid]) - int(flat.Green[mid]); diff < -tolerance || diff > tolerance {
t.Fatalf("mid gray should hold under contrast: got %d, flat %d", high.Green[mid], flat.Green[mid])
}
if high.Green[quarter] >= flat.Green[quarter] {
t.Fatalf("quarter gray should drop under contrast 2.0: got %d, flat %d", high.Green[quarter], flat.Green[quarter])
}
})
}
+89
View File
@@ -31,6 +31,18 @@ func HandleRequest(conn *models.Conn, req models.Request, manager *Manager) {
handleSetEnabled(conn, req, manager)
case "wayland.gamma.subscribe":
handleSubscribe(conn, req, manager)
case "wayland.icc.getStatus":
handleICCGetStatus(conn, req, manager)
case "wayland.icc.apply":
handleICCApply(conn, req, manager)
case "wayland.icc.remove":
handleICCRemove(conn, req, manager)
case "wayland.icc.listOutputs":
handleICCListOutputs(conn, req, manager)
case "wayland.icc.setTemp":
handleICCSetTemp(conn, req, manager)
case "wayland.icc.getTemps":
handleICCGetTemps(conn, req, manager)
default:
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
}
@@ -189,3 +201,80 @@ func handleSubscribe(conn *models.Conn, req models.Request, manager *Manager) {
}
}
}
func handleICCGetStatus(conn *models.Conn, req models.Request, manager *Manager) {
status := manager.GetICCStatus()
outputs := manager.ListOutputs()
result := map[string]any{
"outputs": outputs,
"profiles": status,
}
models.Respond(conn, req.ID, result)
}
func handleICCApply(conn *models.Conn, req models.Request, manager *Manager) {
output, err := params.String(req.Params, "output")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
path, err := params.String(req.Params, "path")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := manager.ApplyICC(output, path); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "ICC profile applied"})
}
func handleICCRemove(conn *models.Conn, req models.Request, manager *Manager) {
output, err := params.String(req.Params, "output")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := manager.RemoveICC(output); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "ICC profile removed"})
}
func handleICCListOutputs(conn *models.Conn, req models.Request, manager *Manager) {
outputs := manager.ListOutputs()
models.Respond(conn, req.ID, outputs)
}
func handleICCSetTemp(conn *models.Conn, req models.Request, manager *Manager) {
output, err := params.String(req.Params, "output")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
temp, err := params.Int(req.Params, "temp")
if err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
if err := manager.SetOutputTemp(output, temp); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "Output temperature set"})
}
func handleICCGetTemps(conn *models.Conn, req models.Request, manager *Manager) {
temps := manager.GetOutputTemps()
models.Respond(conn, req.ID, temps)
}
+678 -67
View File
@@ -6,8 +6,11 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
@@ -17,6 +20,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/icc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
"github.com/AvengeMedia/DankMaterialShell/core/internal/wayland/shm"
@@ -26,6 +30,11 @@ const animKelvinStep = 25
const neutralTemp = 6500
// noTempTarget marks "the night light has no temperature target". Plain outputs
// then get the neutral ramp, while outputs with an ICC profile stay at the
// white point their profile was produced at.
const noTempTarget = -1
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
if err := config.Validate(); err != nil {
return nil, err
@@ -58,6 +67,9 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
}
m.alive = true
// Publish before the first state push so the snapshot is never nil, and
// before the actor starts so no other goroutine touches the fields yet.
m.publishICCState()
m.recalcSchedule(time.Now())
m.updateStateFromSchedule()
@@ -75,13 +87,31 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
m.wg.Add(1)
go m.waylandActor()
// needsControls() also accounts for ICC profiles and per-output
// temperatures, so controls are set up whenever any of them is configured.
if m.needsControls() {
m.post(func() {
if m.controlsInitialized {
return
// Profiles and temperatures are configured state, not control state:
// the registry handler may already have established the controls, and
// the configuration still has to be attached.
loaded := m.loadConfiguredICC()
m.publishICCState()
if !m.controlsInitialized {
log.Info("Gamma control enabled at startup")
m.createControls()
}
if loaded {
// The attached configuration is invisible to the per-output
// dedup, which only compares the schedule-derived
// temp/gamma/contrast, so clear that state before applying.
m.outputs.Range(func(_ uint32, out *outputState) bool {
out.lastTemp = 0
return true
})
m.applyCurrentTemp("startup")
}
log.Info("Gamma control enabled at startup")
m.createControls()
})
}
@@ -96,6 +126,24 @@ func (m *Manager) post(fn func()) {
}
}
// cloneConfig returns a copy of cfg whose maps belong to the copy. The manager
// mutates the config's maps under configMutex, so a value copy that still
// aliased them would turn a marshal or an iteration after the unlock into a
// concurrent map read/write.
func cloneConfig(cfg Config) Config {
cfg.ICCProfiles = maps.Clone(cfg.ICCProfiles)
cfg.OutputTemps = maps.Clone(cfg.OutputTemps)
return cfg
}
// configSnapshot returns a clone of the current config that stays valid after
// configMutex is released.
func (m *Manager) configSnapshot() Config {
m.configMutex.RLock()
defer m.configMutex.RUnlock()
return cloneConfig(m.config)
}
func (m *Manager) waylandActor() {
defer m.wg.Done()
for {
@@ -165,6 +213,11 @@ func (m *Manager) setupRegistry() error {
outputID := output.ID()
output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
outputNames[outputID] = ev.Name
m.outputNames.Store(outputID, ev.Name)
m.post(func() {
m.attachConfiguredICC(outputID, ev.Name)
m.updateStateFromSchedule()
})
})
if gammaMgr != nil {
outputs = append(outputs, output)
@@ -197,37 +250,8 @@ func (m *Manager) setupRegistry() error {
registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
m.post(func() {
var foundID uint32
var foundOut *outputState
m.outputs.Range(func(id uint32, out *outputState) bool {
if out.registryName == e.Name {
foundID = id
foundOut = out
return false
}
return true
})
if foundOut == nil {
return
}
if foundOut.gammaControl != nil {
foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
foundOut.gammaControl = nil
}
m.removeAvailableOutput(foundOut.output)
if foundOut.output != nil && !foundOut.output.IsZombie() {
_ = foundOut.output.Release()
}
m.outputs.Delete(foundID)
hasOutputs := false
m.outputs.Range(func(_ uint32, _ *outputState) bool {
hasOutputs = true
return false
})
if !hasOutputs {
m.controlsInitialized = false
}
m.removeOutputByRegistryName(e.Name)
m.updateStateFromSchedule()
})
})
@@ -267,16 +291,40 @@ func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_g
}
outputID := output.ID()
registryName, _ := m.outputRegNames.Load(outputID)
outState := &outputState{
outState := m.controlStateFor(outputID, registryName, output, control)
m.setupControlHandlers(outState, control)
m.outputs.Store(outputID, outState)
}
return nil
}
// controlStateFor returns the state a (re)created gamma control is attached to.
// A re-created control must not drop what belongs to the output rather than to
// the control: the attached ICC profile and the per-output temperature survive,
// while the ramp bookkeeping and the failure budget start over so the new
// control receives a fresh ramp once it reports gamma_size.
func (m *Manager) controlStateFor(outputID, registryName uint32, output *wlclient.Output, control any) *outputState {
existing, ok := m.outputs.Load(outputID)
if !ok {
return &outputState{
id: outputID,
registryName: registryName,
output: output,
gammaControl: control,
}
m.setupControlHandlers(outState, control)
m.outputs.Store(outputID, outState)
}
return nil
existing.registryName = registryName
existing.output = output
existing.gammaControl = control
existing.rampSize = 0
existing.failed = false
existing.retryCount = 0
existing.lastFailTime = time.Time{}
existing.lastTemp = 0
existing.lastGamma = 0
existing.lastContrast = 0
return existing
}
func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) {
@@ -353,6 +401,79 @@ func (m *Manager) removeAvailableOutput(o *wlclient.Output) {
})
}
// removeOutputByRegistryName tears down the state of an output that left the
// compositor (unplugged or asleep). Only the wayland actor goroutine calls it.
func (m *Manager) removeOutputByRegistryName(registryName uint32) {
// Resolve the object ID from the registry name map: both name maps are
// populated for every wl_output, whether or not a gamma control exists for
// it, while m.outputs only has entries once the controls exist. On a default
// install (night light off, no profile, no override) no control is ever
// created, and the name entries still have to go with the output.
foundID, found := m.outputIDForRegistryName(registryName)
var foundOut *outputState
if found {
if out, ok := m.outputs.Load(foundID); ok {
foundOut = out
}
} else {
// An output whose name never arrived is only known through its state.
m.outputs.Range(func(id uint32, out *outputState) bool {
if out.registryName == registryName {
foundID, foundOut, found = id, out, true
return false
}
return true
})
}
if !found {
return
}
// The name entries go first: they are what `dms icc status`/`listOutputs`
// report, and a rebound wl_output reusing a released object ID would
// otherwise attach the previous monitor's profile from the stale name.
m.outputNames.Delete(foundID)
m.outputRegNames.Delete(foundID)
if foundOut != nil {
if foundOut.gammaControl != nil {
foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
foundOut.gammaControl = nil
}
m.removeAvailableOutput(foundOut.output)
if foundOut.output != nil && !foundOut.output.IsZombie() {
_ = foundOut.output.Release()
}
m.outputs.Delete(foundID)
}
m.publishICCState()
hasOutputs := false
m.outputs.Range(func(_ uint32, _ *outputState) bool {
hasOutputs = true
return false
})
if !hasOutputs {
m.controlsInitialized = false
}
}
// outputIDForRegistryName maps a wl_output registry name back to the object ID
// it was announced with.
func (m *Manager) outputIDForRegistryName(registryName uint32) (uint32, bool) {
var foundID uint32
var found bool
m.outputRegNames.Range(func(id, name uint32) bool {
if name == registryName {
foundID, found = id, true
return false
}
return true
})
return foundID, found
}
func (m *Manager) outputStillValid(out *outputState) bool {
switch {
case out == nil:
@@ -411,18 +532,21 @@ func (m *Manager) addOutputControl(output *wlclient.Output) error {
}
m.setupControlHandlers(outState, control)
m.outputs.Store(outputID, outState)
// The name may already be known (hotplug after the output was announced),
// in which case the configured profile/temperature attaches right away.
if name, ok := m.outputNames.Load(outputID); ok {
m.attachConfiguredICC(outputID, name)
}
m.publishICCState()
return nil
}
func (m *Manager) recreateOutputControl(out *outputState) error {
m.configMutex.RLock()
enabled := m.config.Enabled
m.configMutex.RUnlock()
switch {
case m.connectionDead.Load():
return nil
case !enabled || !m.controlsInitialized:
case !m.needsControls() || !m.controlsInitialized:
return nil
case out.isVirtual:
return nil
@@ -467,9 +591,7 @@ func (m *Manager) markConnectionDead(err error) {
}
func (m *Manager) recalcSchedule(now time.Time) {
m.configMutex.RLock()
config := m.config
m.configMutex.RUnlock()
config := m.configSnapshot()
m.scheduleMutex.Lock()
defer m.scheduleMutex.Unlock()
@@ -540,9 +662,7 @@ func (m *Manager) SetGeoClient(client geolocation.Client) {
}
func (m *Manager) getLocation() (*float64, *float64) {
m.configMutex.RLock()
config := m.config
m.configMutex.RUnlock()
config := m.configSnapshot()
if config.Latitude != nil && config.Longitude != nil {
return config.Latitude, config.Longitude
@@ -694,9 +814,7 @@ func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time
func (m *Manager) tomorrowDawn(now time.Time) time.Time {
tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
m.configMutex.RLock()
config := m.config
m.configMutex.RUnlock()
config := m.configSnapshot()
if config.ManualSunrise != nil {
dur := time.Hour
@@ -785,8 +903,10 @@ func (m *Manager) applyCurrentTemp(_ string) {
low, high := m.config.LowTemp, m.config.HighTemp
m.configMutex.RUnlock()
// With the night light disabled, outputs with an ICC profile keep the ramp
// their profile describes and the rest stay at identity (noTempTarget).
if !enabled {
m.applyGamma(neutralTemp)
m.applyGamma(noTempTarget)
m.updateStateFromSchedule()
return
}
@@ -810,6 +930,20 @@ func (m *Manager) applyCurrentTemp(_ string) {
m.updateStateFromSchedule()
}
// effectiveTempTarget returns the temperature to render an output at: a
// per-output override wins over the night light schedule, so each display can
// keep its own temperature (0 = no override). Without either, the output keeps
// whatever its profile describes.
func effectiveTempTarget(out *outputState, scheduleTemp int) int {
if out.outputTemp != 0 {
return out.outputTemp
}
if scheduleTemp > 0 {
return scheduleTemp
}
return noTempTarget
}
func (m *Manager) applyGamma(temp int) {
m.configMutex.RLock()
gamma, contrast := m.config.Gamma, m.config.Contrast
@@ -832,12 +966,14 @@ func (m *Manager) applyGamma(temp int) {
}
type job struct {
out *outputState
data []byte
out *outputState
targetTemp int
data []byte
}
var jobs []job
for _, out := range outs {
targetTemp := effectiveTempTarget(out, temp)
switch {
case out.failed:
continue
@@ -845,12 +981,36 @@ func (m *Manager) applyGamma(temp int) {
continue
case out.gammaControl == nil:
continue
case out.rampCurrent(temp, gamma, contrast):
case out.rampCurrent(targetTemp, gamma, contrast):
continue
case !m.outputStillValid(out):
continue
}
ramp := GenerateGammaRamp(out.rampSize, temp, gamma, contrast)
var ramp GammaRamp
if out.iccPath != "" && out.iccProfile != nil {
// The profile describes the display at the white point display
// profiles are produced at (D65); a target temperature is composed
// on top of it.
profileRamp, err := ProfileRampWithTemp(out.rampSize, out.iccProfile, neutralTemp, targetTemp, gamma, contrast)
if err != nil {
log.Warnf("icc: failed to generate ramp for output %d: %v, falling back to temperature", out.id, err)
fallbackTemp := targetTemp
if fallbackTemp <= 0 {
fallbackTemp = neutralTemp
}
ramp = GenerateGammaRamp(out.rampSize, fallbackTemp, gamma, contrast)
} else {
ramp = profileRamp
log.Debugf("icc: applied ICC ramp to output %d (size=%d, ref=%dK, target=%dK)", out.id, out.rampSize, neutralTemp, targetTemp)
}
} else {
outTemp := targetTemp
if outTemp <= 0 {
outTemp = neutralTemp
}
ramp = GenerateGammaRamp(out.rampSize, outTemp, gamma, contrast)
}
buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6))
for _, v := range ramp.Red {
binary.Write(buf, binary.LittleEndian, v)
@@ -861,13 +1021,13 @@ func (m *Manager) applyGamma(temp int) {
for _, v := range ramp.Blue {
binary.Write(buf, binary.LittleEndian, v)
}
jobs = append(jobs, job{out: out, data: buf.Bytes()})
jobs = append(jobs, job{out: out, targetTemp: targetTemp, data: buf.Bytes()})
}
for _, j := range jobs {
err := m.setGammaBytes(j.out, j.data)
if err == nil {
j.out.lastTemp = temp
j.out.lastTemp = j.targetTemp
j.out.lastGamma = gamma
j.out.lastContrast = contrast
continue
@@ -883,8 +1043,11 @@ func (m *Manager) applyGamma(temp int) {
}
}
func (out *outputState) rampCurrent(temp int, gamma, contrast float64) bool {
return out.lastTemp == temp && out.lastGamma == gamma && out.lastContrast == contrast
// rampCurrent compares against the output's effective target, not the schedule
// temperature, so an overridden output is not resent on every schedule step.
// lastTemp 0 never matches a target and marks a forced resend.
func (out *outputState) rampCurrent(targetTemp int, gamma, contrast float64) bool {
return out.lastTemp == targetTemp && out.lastGamma == gamma && out.lastContrast == contrast
}
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
@@ -924,9 +1087,7 @@ func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
func (m *Manager) updateStateFromSchedule() {
now := time.Now()
m.configMutex.RLock()
config := m.config
m.configMutex.RUnlock()
config := m.configSnapshot()
m.scheduleMutex.RLock()
times := m.schedule.times
@@ -960,6 +1121,9 @@ func (m *Manager) updateStateFromSchedule() {
NightTime: times.Night,
IsDay: isDay,
SunPosition: pos,
ICCProfiles: m.GetICCStatus(),
OutputTemps: m.GetOutputTemps(),
Outputs: m.ListOutputs(),
}
m.stateMutex.Lock()
@@ -1074,7 +1238,7 @@ func (m *Manager) SetConfig(config Config) error {
return err
}
m.configMutex.Lock()
m.config = config
m.config = cloneConfig(config)
m.configMutex.Unlock()
m.triggerUpdate()
return nil
@@ -1224,7 +1388,11 @@ func (m *Manager) SetEnabled(enabled bool) {
func (m *Manager) needsControls() bool {
m.configMutex.RLock()
defer m.configMutex.RUnlock()
return m.config.Enabled || m.config.Gamma != 1.0 || m.config.Contrast != 1.0
return m.config.Enabled ||
m.config.Gamma != 1.0 ||
m.config.Contrast != 1.0 ||
len(m.config.ICCProfiles) > 0 ||
len(m.config.OutputTemps) > 0
}
func (m *Manager) syncControls() {
@@ -1240,6 +1408,31 @@ func (m *Manager) syncControls() {
})
}
// ensureOutputControls creates the gamma controls when the manager has none.
// Called on the wayland actor goroutine. A default install (night light off,
// gamma and contrast neutral, no profile, no override) has no controls, but
// applying an ICC profile or a per-output temperature still needs them.
func (m *Manager) ensureOutputControls() bool {
if m.controlsInitialized {
return true
}
gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
if !ok || gammaMgr == nil {
log.Warn("icc: gamma control manager not available")
return false
}
m.availOutputsMu.RLock()
outs := slices.Clone(m.availableOutputs)
m.availOutputsMu.RUnlock()
if err := m.setupOutputControls(outs, gammaMgr); err != nil {
log.Errorf("icc: failed to initialize output controls: %v", err)
return false
}
m.controlsInitialized = true
log.Info("icc: output controls initialized (gamma was disabled)")
return true
}
func (m *Manager) createControls() {
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
m.availOutputsMu.RLock()
@@ -1250,6 +1443,9 @@ func (m *Manager) createControls() {
return
}
m.controlsInitialized = true
// Recreated controls carry fresh output state, so the snapshot has to
// follow it rather than keep reporting what the old state held.
m.publishICCState()
m.triggerUpdate()
}
@@ -1265,6 +1461,421 @@ func (m *Manager) destroyControls() {
return true
})
m.controlsInitialized = false
m.publishICCState()
}
// loadConfiguredICC attaches the configured ICC profiles and per-output
// temperatures to the outputs that are already known, and reports whether
// anything was attached.
func (m *Manager) loadConfiguredICC() bool {
config := m.configSnapshot()
iccProfiles := config.ICCProfiles
outputTemps := config.OutputTemps
if len(iccProfiles) == 0 && len(outputTemps) == 0 {
return false
}
attached := 0
m.outputs.Range(func(_ uint32, out *outputState) bool {
name, ok := m.outputNames.Load(out.id)
if !ok {
return true
}
if m.applyConfiguredICCForOutput(out, name, iccProfiles, outputTemps) {
attached++
}
return true
})
log.Infof("icc: config has %d profile(s) and %d temperature override(s), attached to %d output(s)",
len(iccProfiles), len(outputTemps), attached)
m.publishICCState()
return attached > 0
}
// attachConfiguredICC attaches the configured profile and temperature to an
// output whose name just became known, so a hotplugged output comes up
// corrected without an explicit re-apply.
func (m *Manager) attachConfiguredICC(outputID uint32, outputName string) {
config := m.configSnapshot()
iccProfiles := config.ICCProfiles
outputTemps := config.OutputTemps
if len(iccProfiles) == 0 && len(outputTemps) == 0 {
m.publishICCState()
return
}
changed := false
m.outputs.Range(func(_ uint32, out *outputState) bool {
if out.id != outputID {
return true
}
changed = m.applyConfiguredICCForOutput(out, outputName, iccProfiles, outputTemps)
if changed {
log.Infof("icc: attached configured profile/temperature to output %s", outputName)
out.lastTemp = 0
}
return false
})
// Publish either way: a hotplugged output is new to the snapshot even when
// it has no configured profile or temperature.
m.publishICCState()
if changed {
m.applyCurrentTemp("output-name")
}
}
// applyConfiguredICCForOutput applies the configured profile and temperature of
// one output and reports whether anything changed.
func (m *Manager) applyConfiguredICCForOutput(out *outputState, outputName string, iccProfiles map[string]string, outputTemps map[string]int) bool {
changed := false
if temp, ok := outputTemps[outputName]; ok {
out.outputTemp = temp
changed = true
}
if iccPath, ok := iccProfiles[outputName]; ok {
profile, err := icc.ParseFile(iccPath)
switch {
case err != nil:
log.Warnf("icc: failed to load profile for output %q: %v", outputName, err)
case !profile.HasVCGT:
// Only vcgt can be written to the GPU ramp; the TRC curve describes
// the display itself and would wash the output out if applied.
log.Warnf("icc: profile %q for output %q has no vcgt table, not attaching it", iccPath, outputName)
default:
out.iccPath = iccPath
out.iccProfile = profile
changed = true
}
}
return changed
}
// validateOutputName rejects names no wl_output can report, so a typo or an
// empty argument cannot persist an entry in wayland.json that nothing will ever
// match.
func validateOutputName(name string) error {
switch {
case name == "":
return errors.New("icc: output name is required")
case strings.TrimSpace(name) != name:
return fmt.Errorf("icc: output name %q has surrounding whitespace", name)
}
return nil
}
// validateICCProfilePath rejects paths that cannot be resolved later: the path
// is persisted and read again on the next start, when the daemon no longer has
// the working directory the caller had.
func validateICCProfilePath(path string) error {
switch {
case path == "":
return errors.New("icc: profile path is required")
case !filepath.IsAbs(path):
return fmt.Errorf("icc: profile path %q must be absolute", path)
}
return nil
}
// ApplyICC loads and applies an ICC profile to a specific output.
// outputName is the wl_output name (e.g., "DP-1").
// iccPath is the path to the .icm/.icc profile file.
func (m *Manager) ApplyICC(outputName, iccPath string) error {
if err := validateOutputName(outputName); err != nil {
return err
}
if err := validateICCProfilePath(iccPath); err != nil {
return err
}
// 1. Parse and validate the ICC profile
profile, err := icc.ParseFile(iccPath)
if err != nil {
return fmt.Errorf("icc: failed to parse %s: %w", iccPath, err)
}
if profile.ColorSpace != "RGB" {
return fmt.Errorf("icc: unsupported color space %q, only RGB supported", profile.ColorSpace)
}
// Only vcgt holds a video card gamma table, which is what the GPU ramp
// takes. The TRC tags describe the display's own transfer function, so a
// profile without vcgt has no correction to apply.
if !profile.HasVCGT {
return fmt.Errorf("icc: %s has no vcgt table, so it has no gamma ramp to apply", iccPath)
}
// 2. Post to wayland actor thread to apply
m.post(func() {
defer m.updateStateFromSchedule()
// ICC works with the night light off, but the gamma controls that carry
// the ramp may not exist yet, so create them before attaching.
if !m.ensureOutputControls() {
return
}
// Find the output by name
var targetOutput *outputState
m.outputs.Range(func(_ uint32, out *outputState) bool {
if name, ok := m.outputNames.Load(out.id); ok && name == outputName {
targetOutput = out
return false // stop
}
return true
})
if targetOutput == nil {
log.Warnf("icc: output %q not found", outputName)
return
}
targetOutput.iccPath = iccPath
targetOutput.iccProfile = profile
m.publishICCState()
// The new profile is invisible to the per-output dedup, so clear this
// output's state to force a fresh ramp.
targetOutput.lastTemp = 0
m.applyCurrentTemp("icc-apply")
})
// 3. Persist to config
m.configMutex.Lock()
if m.config.ICCProfiles == nil {
m.config.ICCProfiles = make(map[string]string)
}
m.config.ICCProfiles[outputName] = iccPath
savedConfig := cloneConfig(m.config)
m.configMutex.Unlock()
// Save to disk. Only the ICC fields are written: the night light fields in
// this config belong to the shell, not to the daemon.
if err := SaveICCConfig(savedConfig.ICCProfiles, savedConfig.OutputTemps); err != nil {
log.Warnf("icc: failed to save config: %v", err)
}
return nil
}
// RemoveICC removes the ICC profile from a specific output, reverting to temperature-based gamma.
func (m *Manager) RemoveICC(outputName string) error {
if err := validateOutputName(outputName); err != nil {
return err
}
m.configMutex.Lock()
delete(m.config.ICCProfiles, outputName)
savedConfig := cloneConfig(m.config)
m.configMutex.Unlock()
if err := SaveICCConfig(savedConfig.ICCProfiles, savedConfig.OutputTemps); err != nil {
log.Warnf("icc: failed to save config: %v", err)
}
m.post(func() {
defer m.updateStateFromSchedule()
m.outputs.Range(func(_ uint32, out *outputState) bool {
if name, ok := m.outputNames.Load(out.id); ok && name == outputName {
if out.iccPath == "" {
return false
}
out.iccPath = ""
out.iccProfile = nil
out.lastTemp = 0
return false
}
return true
})
if m.releaseIdleControls() {
return
}
m.publishICCState()
m.applyCurrentTemp("icc-remove")
})
return nil
}
// releaseIdleControls tears the gamma controls down once nothing needs them,
// so another gamma client can take over. Called on the wayland actor goroutine.
func (m *Manager) releaseIdleControls() bool {
if m.needsControls() || !m.controlsInitialized {
return false
}
m.destroyControls()
return true
}
// publishICCState republishes the ICC snapshot the getters serve. The
// per-output ICC fields are written on the wayland actor goroutine only, so
// this must run there; the getters are called from the IPC handlers, the CLI
// and the scheduler goroutine, which is why they never touch those fields.
// Publishing on ICC changes rather than on every state push also keeps the
// per-profile stat below out of the night light animation path. The status map
// reflects what is attached to an output; the temperature map reports the
// configured value, so a value set for an output that is not known yet is still
// reported instead of snapping the settings slider back to "Default".
func (m *Manager) publishICCState() {
status := make(map[string]*ICCStatus)
temps := make(map[string]int)
m.configMutex.RLock()
for name, temp := range m.config.OutputTemps {
if temp != 0 {
temps[name] = temp
}
}
m.configMutex.RUnlock()
m.outputs.Range(func(_ uint32, out *outputState) bool {
name, ok := m.outputNames.Load(out.id)
if !ok {
return true
}
if _, configured := temps[name]; !configured && out.outputTemp != 0 {
temps[name] = out.outputTemp
}
if out.iccPath != "" {
status[name] = describeICCProfile(out.iccPath, out.iccProfile)
}
return true
})
m.iccStateMutex.Lock()
m.iccStatus = status
m.iccTemps = temps
m.iccStateMutex.Unlock()
}
// describeICCProfile builds the record served to the CLI and the settings UI.
// It is the only place that stats the profile file.
func describeICCProfile(path string, profile *icc.Profile) *ICCStatus {
status := &ICCStatus{
Path: path,
Active: profile != nil,
}
if profile != nil {
status.Description = profile.Description
status.Version = profile.Version
status.ColorSpace = profile.ColorSpace
status.Class = profile.Class
status.HasVCGT = profile.HasVCGT
status.TRCKind, status.TRCGamma, status.TRCEntries = profile.TRCKind()
if profile.VCGT != nil {
status.VCGTChannels = profile.VCGT.Channels
status.VCGTEntries = profile.VCGT.Entries
}
if x, y, ok := profile.WhitePointXY(); ok {
status.WhitePointX = x
status.WhitePointY = y
status.WhitePointCCT = profile.WhitePointCCT()
status.WhitePointName = profile.WhitePointName()
}
}
if info, err := os.Stat(path); err == nil {
status.Size = info.Size()
status.Modified = info.ModTime().Unix()
}
return status
}
// GetICCStatus returns the published ICC status for all outputs.
func (m *Manager) GetICCStatus() map[string]*ICCStatus {
m.iccStateMutex.RLock()
defer m.iccStateMutex.RUnlock()
return maps.Clone(m.iccStatus)
}
// ListOutputs returns a list of all output names for ICC assignment.
func (m *Manager) ListOutputs() []string {
var names []string
m.outputNames.Range(func(_ uint32, name string) bool {
names = append(names, name)
return true
})
slices.Sort(names)
return names
}
// SetOutputTemp sets a per-output color temperature (1000K-10000K).
// A value of 0 resets to the global default.
func (m *Manager) SetOutputTemp(outputName string, temp int) error {
if err := validateOutputName(outputName); err != nil {
return err
}
if temp != 0 && (temp < 1000 || temp > 10000) {
return fmt.Errorf("temperature %d out of range (1000-10000)", temp)
}
// Persist first: on a default install (night light off, no profile) no
// gamma control exists yet, and the stored value is what makes
// needsControls() true for the controls the apply below creates.
m.configMutex.Lock()
if m.config.OutputTemps == nil {
m.config.OutputTemps = make(map[string]int)
}
if temp == 0 {
delete(m.config.OutputTemps, outputName)
} else {
m.config.OutputTemps[outputName] = temp
}
savedConfig := cloneConfig(m.config)
m.configMutex.Unlock()
if err := SaveICCConfig(savedConfig.ICCProfiles, savedConfig.OutputTemps); err != nil {
log.Warnf("icc: failed to save config: %v", err)
}
m.post(func() {
defer m.updateStateFromSchedule()
if m.releaseIdleControls() {
return
}
if !m.needsControls() {
m.publishICCState()
return
}
// Without controls there are no outputs to write a ramp for, which is
// why the slider and `dms icc set-temp` used to do nothing until a
// restart on an install that had never enabled the night light.
m.ensureOutputControls()
m.outputs.Range(func(_ uint32, out *outputState) bool {
if name, ok := m.outputNames.Load(out.id); ok && name == outputName {
if out.outputTemp == temp {
return false
}
out.outputTemp = temp
out.lastTemp = 0
m.publishICCState()
m.applyCurrentTemp("output-temp-change")
return false
}
return true
})
// An output whose name is not known yet picks the value up through
// attachConfiguredICC; the published state carries the configured value
// either way, so the settings slider does not snap back to Default.
m.publishICCState()
})
return nil
}
// GetOutputTemps returns the published per-output temperatures.
func (m *Manager) GetOutputTemps() map[string]int {
m.iccStateMutex.RLock()
defer m.iccStateMutex.RUnlock()
return maps.Clone(m.iccTemps)
}
func (m *Manager) Close() {
@@ -1,13 +1,20 @@
package wayland
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/internal/icc"
mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control"
)
@@ -530,3 +537,628 @@ func TestOutputState_RampCurrent(t *testing.T) {
assert.False(t, out.rampCurrent(5000, 1.2, 1.0))
assert.False(t, out.rampCurrent(5000, 1.0, 1.4))
}
// needsControls decides whether gamma controls are created at all. ICC
// profiles and per-output temperatures apply independently of the night light
// schedule, so they have to keep the controls alive: otherwise turning the
// night light off tears the controls down and drops the ICC ramps.
func TestManager_NeedsControlsCoversICCAndOutputTemps(t *testing.T) {
base := Config{Enabled: false, Gamma: 1.0, Contrast: 1.0}
cases := []struct {
name string
cfg Config
want bool
}{
{"idle", base, false},
{"night light on", Config{Enabled: true, Gamma: 1.0, Contrast: 1.0}, true},
{"gamma tweak", Config{Gamma: 1.2, Contrast: 1.0}, true},
{"contrast tweak", Config{Gamma: 1.0, Contrast: 1.2}, true},
{"icc profile only", Config{Gamma: 1.0, Contrast: 1.0, ICCProfiles: map[string]string{"DP-1": "/tmp/display.icc"}}, true},
{"output temp only", Config{Gamma: 1.0, Contrast: 1.0, OutputTemps: map[string]int{"DP-1": 7000}}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := &Manager{config: tc.cfg}
assert.Equal(t, tc.want, m.needsControls())
})
}
}
// The registry handler can establish the gamma controls before the startup post
// runs, so loading the configured ICC profiles and temperatures must not depend
// on the controls still being uninitialized.
func TestManager_LoadConfiguredICCWhenControlsAlreadyExist(t *testing.T) {
dir := t.TempDir()
m := &Manager{config: Config{
ICCProfiles: map[string]string{"DP-2": filepath.Join(dir, "missing.icm")},
OutputTemps: map[string]int{"DP-1": 7000},
}}
m.controlsInitialized = true
dp1 := &outputState{id: 1}
m.outputs.Store(1, dp1)
m.outputNames.Store(1, "DP-1")
dp2 := &outputState{id: 2}
m.outputs.Store(2, dp2)
m.outputNames.Store(2, "DP-2")
if !m.loadConfiguredICC() {
t.Fatal("loadConfiguredICC() = false, want true")
}
assert.Equal(t, 7000, dp1.outputTemp, "configured temperature should attach")
// An unreadable profile must be skipped without aborting the rest.
assert.Empty(t, dp2.iccPath, "unparsable profile should not attach")
}
// A hotplugged output gets its configured profile and temperature attached as
// soon as its name is known.
func TestManager_AttachConfiguredICCForNamedOutput(t *testing.T) {
m := &Manager{config: Config{OutputTemps: map[string]int{"DP-3": 6500}}}
configured := &outputState{id: 3}
m.outputs.Store(3, configured)
m.outputNames.Store(3, "DP-3")
m.attachConfiguredICC(3, "DP-3")
assert.Equal(t, 6500, configured.outputTemp)
plain := &outputState{id: 4}
m.outputs.Store(4, plain)
m.attachConfiguredICC(4, "HDMI-A-1")
assert.Zero(t, plain.outputTemp, "outputs without configuration are left alone")
}
// Each display can keep its own temperature: a per-output override wins over the
// night light schedule, and 0 means "no override" rather than 0K.
func TestEffectiveTempTarget(t *testing.T) {
cases := []struct {
name string
outputTemp int
scheduleTemp int
want int
}{
{"override wins over the schedule", 7000, 5000, 7000},
{"override applies without a schedule", 7000, noTempTarget, 7000},
{"schedule applies without an override", 0, 5000, 5000},
{"neither configured", 0, noTempTarget, noTempTarget},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := &outputState{outputTemp: tc.outputTemp}
assert.Equal(t, tc.want, effectiveTempTarget(out, tc.scheduleTemp))
})
}
}
// A re-created gamma control (night light toggled, outputs re-enumerated) must
// not drop the configured ICC state of the output it belongs to.
func TestManager_ControlStateReuseKeepsAttachedICC(t *testing.T) {
m := &Manager{}
existing := &outputState{
id: 1,
registryName: 10,
iccPath: "/tmp/display.icm",
iccProfile: &icc.Profile{Description: "Test Display"},
outputTemp: 7000,
rampSize: 256,
failed: true,
retryCount: 3,
lastFailTime: time.Now(),
lastTemp: 7000,
lastGamma: 1.1,
lastContrast: 0.9,
}
m.outputs.Store(1, existing)
got := m.controlStateFor(1, 10, nil, "control-2")
assert.Same(t, existing, got, "the output keeps its state across control re-creation")
assert.Equal(t, "/tmp/display.icm", got.iccPath, "the attached profile survives")
assert.Equal(t, 7000, got.outputTemp, "the per-output temperature survives")
assert.Equal(t, "control-2", got.gammaControl, "the new control is attached")
assert.Zero(t, got.rampSize, "the new control has not reported gamma_size yet")
assert.False(t, got.failed)
assert.Zero(t, got.retryCount)
assert.Zero(t, got.lastTemp, "the ramp has to be written again")
fresh := m.controlStateFor(2, 20, nil, "control-3")
assert.NotSame(t, existing, fresh)
assert.Empty(t, fresh.iccPath, "an output seen for the first time starts clean")
assert.Zero(t, fresh.outputTemp)
}
// A wl_output that has no gamma control (the default install: night light off,
// no profile, no override) exists only in the name maps. It still has to
// disappear from listOutputs/status once it is gone, or every dock/undock cycle
// leaves another monitor in maps that live as long as the daemon.
func TestManager_RemoveOutputByRegistryNameWithoutControl(t *testing.T) {
m := &Manager{}
m.outputNames.Store(9, "HDMI-A-1")
m.outputRegNames.Store(9, 77)
assert.Equal(t, []string{"HDMI-A-1"}, m.ListOutputs())
m.removeOutputByRegistryName(77)
assert.Empty(t, m.ListOutputs(), "a monitor with no control must not stay listed")
_, nameStored := m.outputNames.Load(9)
assert.False(t, nameStored, "the name entry should be gone")
_, regStored := m.outputRegNames.Load(9)
assert.False(t, regStored, "the registry name entry should be gone")
}
// The status payload is what the settings UI shows for a profile, so the
// descriptive metadata has to be carried through.
func TestManager_GetICCStatusDescribesProfile(t *testing.T) {
dir := t.TempDir()
profilePath := filepath.Join(dir, "display.icm")
if err := os.WriteFile(profilePath, []byte("stub"), 0o644); err != nil {
t.Fatalf("write stub profile: %v", err)
}
gamma := icc.Curve{Type: icc.CurveParametric, Gamma: 2.2}
m := &Manager{}
m.outputs.Store(1, &outputState{
id: 1,
iccPath: profilePath,
iccProfile: &icc.Profile{
Description: "Test Display",
Version: "2.1.0",
Class: "mntr",
ColorSpace: "RGB",
HasTRC: true,
TRC: [3]icc.Curve{gamma, gamma, gamma},
HasVCGT: true,
VCGT: &icc.VCGT{Channels: 3, Entries: 1024},
WhitePoint: [3]float64{0.9505, 1.0, 1.0890},
},
})
m.outputNames.Store(1, "DP-2")
m.publishICCState()
status := m.GetICCStatus()["DP-2"]
if status == nil {
t.Fatal("no status for DP-2")
}
assert.True(t, status.Active)
assert.Equal(t, "mntr", status.Class)
assert.Equal(t, "gamma", status.TRCKind)
assert.Equal(t, 2.2, status.TRCGamma)
assert.Equal(t, 3, status.VCGTChannels)
assert.Equal(t, 1024, status.VCGTEntries)
assert.Equal(t, "D65", status.WhitePointName)
assert.Equal(t, int64(4), status.Size)
assert.NotZero(t, status.Modified)
}
// The exported getters serve the published snapshot, so callers on other
// goroutines (IPC handlers, the scheduler) never read the per-output fields the
// actor mutates. A profile therefore appears in the status only once the actor
// has published it, and an empty snapshot has to clear the previous one.
func TestManager_ICCStatusServesPublishedSnapshot(t *testing.T) {
m := &Manager{}
out := &outputState{id: 1, iccPath: "/tmp/display.icm", outputTemp: 7000}
m.outputs.Store(1, out)
m.outputNames.Store(1, "DP-1")
assert.Empty(t, m.GetICCStatus(), "nothing is published before the first publish")
assert.Empty(t, m.GetOutputTemps())
m.publishICCState()
assert.Contains(t, m.GetICCStatus(), "DP-1")
assert.Equal(t, 7000, m.GetOutputTemps()["DP-1"])
// A mutation the actor has not published yet is invisible to callers.
out.outputTemp = 5000
out.iccPath = ""
assert.Equal(t, 7000, m.GetOutputTemps()["DP-1"])
m.publishICCState()
assert.Equal(t, 5000, m.GetOutputTemps()["DP-1"])
assert.Empty(t, m.GetICCStatus(), "the removed profile is gone from the snapshot")
}
// Outputs that go away (unplugged, monitor sleep) must not stay in the name
// maps: they are what `dms icc listOutputs` and `status` enumerate, and a
// rebound wl_output reusing the object ID would attach the previous monitor's
// profile from the stale name.
func TestManager_RemoveOutputByRegistryNamePrunesNames(t *testing.T) {
m := &Manager{}
out := &outputState{id: 7, registryName: 42, iccPath: "/tmp/display.icm", outputTemp: 6500}
m.outputs.Store(7, out)
m.outputNames.Store(7, "DP-1")
m.outputRegNames.Store(7, 42)
m.controlsInitialized = true
m.publishICCState()
m.removeOutputByRegistryName(42)
_, stillStored := m.outputs.Load(7)
assert.False(t, stillStored, "the output state should be gone")
_, nameStored := m.outputNames.Load(7)
assert.False(t, nameStored, "the output name should be gone")
_, regNameStored := m.outputRegNames.Load(7)
assert.False(t, regNameStored, "the registry name should be gone")
assert.Empty(t, m.ListOutputs(), "a disconnected monitor must not be listed")
assert.Empty(t, m.GetICCStatus(), "nor reported as a profiled output")
assert.Empty(t, m.GetOutputTemps())
assert.False(t, m.controlsInitialized, "the last output going away clears the controls")
// A registry name that no output uses is a no-op.
m.removeOutputByRegistryName(99)
}
// The getters are called from the scheduler and the IPC handlers while the
// actor publishes, so they must not share unsynchronised state with it (run
// with -race).
func TestManager_ICCStatusConcurrentPublishAndRead(t *testing.T) {
m := &Manager{}
out := &outputState{id: 1, iccPath: "/tmp/display.icm"}
m.outputs.Store(1, out)
m.outputNames.Store(1, "DP-1")
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := range 500 {
out.outputTemp = 6000 + i
out.iccProfile = nil
m.publishICCState()
}
}()
for range 500 {
_ = m.GetICCStatus()
_ = m.GetOutputTemps()
_ = m.ListOutputs()
}
wg.Wait()
}
// The config is copied by value all over the manager, so a copy has to be a real
// snapshot: the maps must not alias the ones the manager writes under its lock,
// or a state push marshalled on another goroutine becomes a fatal
// "concurrent map iteration and map write".
func TestManager_ConfigSnapshotOwnsItsMaps(t *testing.T) {
m := &Manager{config: DefaultConfig()}
m.config.ICCProfiles = map[string]string{"DP-1": "/tmp/dp1.icc"}
m.config.OutputTemps = map[string]int{"DP-1": 7000}
snapshot := m.configSnapshot()
m.configMutex.Lock()
m.config.ICCProfiles["DP-2"] = "/tmp/dp2.icc"
m.config.OutputTemps["DP-1"] = 4000
m.config.OutputTemps["DP-2"] = 5000
m.configMutex.Unlock()
assert.Equal(t, map[string]string{"DP-1": "/tmp/dp1.icc"}, snapshot.ICCProfiles)
assert.Equal(t, map[string]int{"DP-1": 7000}, snapshot.OutputTemps)
}
// Marshalling the copied config happens on the connection writer goroutine
// while the IPC goroutine stores a profile, so the copy has to be taken under
// the lock and own its maps (run with -race).
func TestManager_ConfigSnapshotConcurrentMarshal(t *testing.T) {
m := &Manager{config: DefaultConfig()}
m.config.ICCProfiles = make(map[string]string)
m.config.OutputTemps = make(map[string]int)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := range 500 {
name := fmt.Sprintf("DP-%d", i)
m.configMutex.Lock()
m.config.ICCProfiles[name] = "/tmp/profile.icc"
m.config.OutputTemps[name] = 6000 + i
m.configMutex.Unlock()
}
}()
for range 500 {
if _, err := json.Marshal(m.configSnapshot()); err != nil {
t.Fatalf("marshal config snapshot: %v", err)
}
}
wg.Wait()
}
// On a default install (night light off, no profile) no gamma control exists,
// so a per-output temperature used to be written to wayland.json without ever
// being applied or reported: the settings slider snapped back to "Default" and
// the value only took effect after a daemon restart.
func TestManager_SetOutputTempWithoutControlsPublishesValue(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
m := &Manager{
config: DefaultConfig(),
cmdq: make(chan cmd, 8),
stopChan: make(chan struct{}),
}
m.wg.Add(1)
go m.waylandActor()
defer func() {
close(m.stopChan)
m.wg.Wait()
}()
if err := m.SetOutputTemp("DP-1", 7000); err != nil {
t.Fatalf("SetOutputTemp() = %v", err)
}
// Wait for the posted body, so the assertions below are ordered after it.
done := make(chan struct{})
m.post(func() { close(done) })
<-done
assert.Equal(t, 7000, m.GetOutputTemps()["DP-1"], "the value is reported even without gamma controls")
path, err := getConfigPath()
if err != nil {
t.Fatalf("config path: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var saved Config
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
assert.Equal(t, 7000, saved.OutputTemps["DP-1"], "and persisted to the config file")
}
// Arguments that no wl_output or file can satisfy are rejected before anything is
// written: an empty name would persist an entry in wayland.json that nothing can
// match, and a relative profile path cannot be resolved by the next start, when
// the daemon no longer has the caller's working directory.
func TestManager_RejectsInvalidICCArguments(t *testing.T) {
m := &Manager{config: DefaultConfig()}
t.Run("empty output name", func(t *testing.T) {
assert.Error(t, m.ApplyICC("", "/tmp/profile.icc"))
assert.Error(t, m.RemoveICC(""))
assert.Error(t, m.SetOutputTemp("", 7000))
assert.Empty(t, m.config.ICCProfiles, "nothing may be stored for a name that cannot exist")
assert.Empty(t, m.config.OutputTemps)
})
t.Run("output name with whitespace", func(t *testing.T) {
assert.Error(t, m.SetOutputTemp(" DP-1", 7000))
assert.Empty(t, m.config.OutputTemps)
})
t.Run("relative profile path", func(t *testing.T) {
assert.Error(t, m.ApplyICC("DP-1", "profile.icc"))
assert.Error(t, m.ApplyICC("DP-1", ""))
assert.Empty(t, m.config.ICCProfiles)
})
}
// A gamma control that the compositor dropped on an output with an ICC profile
// (or a per-output temperature) has to be recreated even while the night light
// is off, because the controls exist for the profile in that case. Gating on
// config.Enabled left out.failed set for the life of the daemon, and applyGamma
// skips those outputs.
func TestManager_RecreateOutputControlFollowsNeedsControls(t *testing.T) {
output := &wlclient.Output{}
out := &outputState{id: 1, registryName: 10, output: output, rampSize: 256, failed: true}
m := &Manager{config: DefaultConfig()}
m.outputs.Store(out.id, out)
m.availOutputsMu.Lock()
m.availableOutputs = []*wlclient.Output{output}
m.availOutputsMu.Unlock()
m.controlsInitialized = true
// Night light off and nothing configured: there is nothing to recreate.
assert.NoError(t, m.recreateOutputControl(out))
// Still no night light, but the output has a profile: the control has to be
// recreated (the missing gamma manager is what stops it in this test).
m.config.ICCProfiles = map[string]string{"DP-1": "/tmp/dp1.icc"}
assert.Error(t, m.recreateOutputControl(out), "a profiled output needs its control back with the night light off")
// A per-output temperature counts as well.
m.config.ICCProfiles = nil
m.config.OutputTemps = map[string]int{"DP-1": 7000}
assert.Error(t, m.recreateOutputControl(out), "so does an output with a temperature override")
}
// Clearing an override is what the reset button in Display Config does: it has to
// drop the stored value and the published entry, so the row goes back to
// "Default" and the output follows the schedule again.
func TestManager_SetOutputTempZeroClearsTheOverride(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
m := &Manager{
config: DefaultConfig(),
cmdq: make(chan cmd, 8),
stopChan: make(chan struct{}),
}
m.wg.Add(1)
go m.waylandActor()
defer func() {
close(m.stopChan)
m.wg.Wait()
}()
if err := m.SetOutputTemp("DP-1", 7000); err != nil {
t.Fatalf("SetOutputTemp(7000) = %v", err)
}
if err := m.SetOutputTemp("DP-1", 0); err != nil {
t.Fatalf("SetOutputTemp(0) = %v", err)
}
done := make(chan struct{})
m.post(func() { close(done) })
<-done
assert.NotContains(t, m.GetOutputTemps(), "DP-1", "the published override has to disappear")
assert.NotContains(t, m.config.OutputTemps, "DP-1", "and so has the stored one")
}
func startTestActor(t *testing.T, m *Manager) func() {
t.Helper()
m.cmdq = make(chan cmd, 8)
m.stopChan = make(chan struct{})
m.wg.Add(1)
go m.waylandActor()
t.Cleanup(func() {
close(m.stopChan)
m.wg.Wait()
})
return func() {
done := make(chan struct{})
m.post(func() { close(done) })
<-done
}
}
func TestManager_ClearingLastOverrideReleasesControls(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := DefaultConfig()
cfg.OutputTemps = map[string]int{"DP-1": 7000}
m := &Manager{config: cfg, controlsInitialized: true}
m.outputs.Store(1, &outputState{id: 1, outputTemp: 7000})
m.outputNames.Store(1, "DP-1")
drain := startTestActor(t, m)
if err := m.SetOutputTemp("DP-1", 0); err != nil {
t.Fatalf("SetOutputTemp() = %v", err)
}
drain()
assert.False(t, m.controlsInitialized, "nothing needs the controls once the last override is gone")
_, kept := m.outputs.Load(1)
assert.False(t, kept, "the output state goes with the controls")
assert.Empty(t, m.GetOutputTemps())
}
func TestManager_ClearingOverrideWithoutControlsCreatesNone(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
m := &Manager{config: DefaultConfig()}
drain := startTestActor(t, m)
if err := m.SetOutputTemp("DP-1", 0); err != nil {
t.Fatalf("SetOutputTemp() = %v", err)
}
drain()
assert.False(t, m.controlsInitialized, "clearing a value nothing holds must not create controls")
}
func TestManager_RemovingLastProfileReleasesControls(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := DefaultConfig()
cfg.ICCProfiles = map[string]string{"DP-1": "/tmp/display.icc"}
m := &Manager{config: cfg, controlsInitialized: true}
m.outputs.Store(1, &outputState{id: 1, iccPath: "/tmp/display.icc"})
m.outputNames.Store(1, "DP-1")
drain := startTestActor(t, m)
if err := m.RemoveICC("DP-1"); err != nil {
t.Fatalf("RemoveICC() = %v", err)
}
drain()
assert.False(t, m.controlsInitialized, "nothing needs the controls once the last profile is gone")
assert.Empty(t, m.GetICCStatus())
}
func TestManager_RemovingProfileKeepsControlsForNightLight(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := DefaultConfig()
cfg.Enabled = true
cfg.ICCProfiles = map[string]string{"DP-1": "/tmp/display.icc"}
m := &Manager{config: cfg, controlsInitialized: true}
m.outputs.Store(1, &outputState{id: 1, iccPath: "/tmp/display.icc"})
m.outputNames.Store(1, "DP-1")
drain := startTestActor(t, m)
if err := m.RemoveICC("DP-1"); err != nil {
t.Fatalf("RemoveICC() = %v", err)
}
drain()
assert.True(t, m.controlsInitialized, "the night light still needs the controls")
_, kept := m.outputs.Load(1)
assert.True(t, kept)
}
func TestManager_StateCarriesSortedOutputs(t *testing.T) {
m := &Manager{config: DefaultConfig()}
m.outputNames.Store(2, "HDMI-A-1")
m.outputNames.Store(1, "DP-1")
m.updateStateFromSchedule()
assert.Equal(t, []string{"DP-1", "HDMI-A-1"}, m.GetState().Outputs)
}
func TestRampCurrentUsesEffectiveTarget(t *testing.T) {
overridden := &outputState{outputTemp: 7000, lastTemp: 7000, lastGamma: 1.0, lastContrast: 1.0}
for _, scheduleTemp := range []int{noTempTarget, 4000, 4025, 6500} {
assert.True(t, overridden.rampCurrent(effectiveTempTarget(overridden, scheduleTemp), 1.0, 1.0),
"an overridden output must not be resent when the schedule moves to %d", scheduleTemp)
}
scheduled := &outputState{lastTemp: 4000, lastGamma: 1.0, lastContrast: 1.0}
assert.True(t, scheduled.rampCurrent(effectiveTempTarget(scheduled, 4000), 1.0, 1.0))
assert.False(t, scheduled.rampCurrent(effectiveTempTarget(scheduled, 4025), 1.0, 1.0))
forced := &outputState{outputTemp: 7000, lastTemp: 0, lastGamma: 1.0, lastContrast: 1.0}
assert.False(t, forced.rampCurrent(effectiveTempTarget(forced, 7000), 1.0, 1.0), "lastTemp 0 always resends")
}
func TestManager_SameOverrideValueDoesNotForceResend(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := DefaultConfig()
cfg.OutputTemps = map[string]int{"DP-1": 7000}
m := &Manager{config: cfg, controlsInitialized: true}
out := &outputState{id: 1, outputTemp: 7000, lastTemp: 7000, lastGamma: 1.0, lastContrast: 1.0}
m.outputs.Store(1, out)
m.outputNames.Store(1, "DP-1")
drain := startTestActor(t, m)
if err := m.SetOutputTemp("DP-1", 7000); err != nil {
t.Fatalf("SetOutputTemp() = %v", err)
}
drain()
assert.Equal(t, 7000, out.lastTemp, "an unchanged override must not clear the dedupe state")
}
func TestManager_RemovingAbsentProfileDoesNotForceResend(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := DefaultConfig()
cfg.Enabled = true
m := &Manager{config: cfg, controlsInitialized: true}
out := &outputState{id: 1, lastTemp: 4000, lastGamma: 1.0, lastContrast: 1.0}
m.outputs.Store(1, out)
m.outputNames.Store(1, "DP-1")
drain := startTestActor(t, m)
if err := m.RemoveICC("DP-1"); err != nil {
t.Fatalf("RemoveICC() = %v", err)
}
drain()
assert.Equal(t, 4000, out.lastTemp, "removing a profile that was never attached must not clear the dedupe state")
}
+201 -9
View File
@@ -1,13 +1,21 @@
package wayland
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation"
"github.com/AvengeMedia/DankMaterialShell/core/internal/icc"
"github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"github.com/AvengeMedia/dankgo/syncmap"
wlclient "github.com/AvengeMedia/dankgo/wayland/client"
"github.com/godbus/dbus/v5"
@@ -36,18 +44,48 @@ type Config struct {
Enabled bool
ElevationTwilight float64
ElevationDaylight float64
ICCProfiles map[string]string `json:"iccProfiles,omitempty"` // outputName -> ICC file path
OutputTemps map[string]int `json:"outputTemps,omitempty"` // outputName -> per-output color temperature (K)
}
type State struct {
Config Config `json:"config"`
CurrentTemp int `json:"currentTemp"`
NextTransition time.Time `json:"nextTransition"`
SunriseTime time.Time `json:"sunriseTime"`
SunsetTime time.Time `json:"sunsetTime"`
DawnTime time.Time `json:"dawnTime"`
NightTime time.Time `json:"nightTime"`
IsDay bool `json:"isDay"`
SunPosition float64 `json:"sunPosition"`
Config Config `json:"config"`
CurrentTemp int `json:"currentTemp"`
NextTransition time.Time `json:"nextTransition"`
SunriseTime time.Time `json:"sunriseTime"`
SunsetTime time.Time `json:"sunsetTime"`
DawnTime time.Time `json:"dawnTime"`
NightTime time.Time `json:"nightTime"`
IsDay bool `json:"isDay"`
SunPosition float64 `json:"sunPosition"`
ICCProfiles map[string]*ICCStatus `json:"iccProfiles"` // outputName -> status
OutputTemps map[string]int `json:"outputTemps"` // outputName -> current temp
Outputs []string `json:"outputs"` // connected output names, sorted
}
// ICCStatus represents the ICC profile status for a single output.
type ICCStatus struct {
Path string `json:"path"` // ICC file path
Description string `json:"description"` // ICC profile description
Version string `json:"version"` // ICC version
ColorSpace string `json:"colorSpace"` // e.g., "RGB"
HasVCGT bool `json:"hasVCGT"` // has video card gamma table
Active bool `json:"active"` // currently applied
// Descriptive profile metadata, surfaced in the UI so a profile can be
// identified without leaving the settings page.
Class string `json:"class,omitempty"` // "mntr", "scnr", "prtr"
TRCKind string `json:"trcKind,omitempty"` // "identity", "gamma", "table", "mixed"
TRCGamma float64 `json:"trcGamma,omitempty"` // when TRCKind is "gamma"
TRCEntries int `json:"trcEntries,omitempty"` // when TRCKind is "table"
VCGTChannels int `json:"vcgtChannels,omitempty"` // channels in the video card gamma table
VCGTEntries int `json:"vcgtEntries,omitempty"` // entries per channel
WhitePointX float64 `json:"whitePointX,omitempty"` // chromaticity x
WhitePointY float64 `json:"whitePointY,omitempty"` // chromaticity y
WhitePointCCT int `json:"whitePointCCT,omitempty"` // correlated color temperature (K)
WhitePointName string `json:"whitePointName,omitempty"` // "D50", "D65", ...
Size int64 `json:"size,omitempty"` // profile file size in bytes
Modified int64 `json:"modified,omitempty"` // profile file mtime, unix seconds
}
type cmd struct {
@@ -75,10 +113,19 @@ type Manager struct {
availableOutputs []*wlclient.Output
availOutputsMu sync.RWMutex
outputRegNames syncmap.Map[uint32, uint32]
outputNames syncmap.Map[uint32, string] // outputID -> wl_output name string (e.g., "DP-1")
outputs syncmap.Map[uint32, *outputState]
controlsInitialized bool
connectionDead atomic.Bool
// The per-output ICC fields (iccPath, iccProfile, outputTemp) are only
// touched by the wayland actor goroutine. The published snapshot below is
// what the IPC handlers, the CLI and the scheduler read, so they never
// reach into outputState from another goroutine.
iccStateMutex sync.RWMutex
iccStatus map[string]*ICCStatus // outputName -> status
iccTemps map[string]int // outputName -> per-output temperature (K)
cmdq chan cmd
alive bool
@@ -118,6 +165,9 @@ type outputState struct {
lastTemp int
lastGamma float64
lastContrast float64
iccPath string // path to ICC profile file, empty if not set
iccProfile *icc.Profile // cached parsed ICC profile (nil if not loaded)
outputTemp int // per-output color temperature (K), 0 = use global temp
}
func DefaultConfig() Config {
@@ -133,6 +183,117 @@ func DefaultConfig() Config {
}
}
// DMSConfigDir returns the compositor-specific DMS config directory, matching
// the layout used by the shell and `dms setup` (niri/dms, hypr/dms, mango/dms).
func DMSConfigDir() string {
return filepath.Join(utils.XDGConfigHome(), compositorConfigDirName(), "dms")
}
// ICCProfilesDir returns the directory scanned for ICC profile files.
func ICCProfilesDir() string {
return filepath.Join(DMSConfigDir(), "icc")
}
// compositorConfigDirName maps the running compositor to its DMS config
// directory name. Compositors without a DMS config layout fall back to niri.
func compositorConfigDirName() string {
switch screenshot.DetectCompositor() {
case screenshot.CompositorHyprland:
return "hypr"
case screenshot.CompositorMango:
return "mango"
default:
return "niri"
}
}
// getConfigPath returns the path to the wayland config file.
func getConfigPath() (string, error) {
dir := DMSConfigDir()
if !filepath.IsAbs(dir) {
return "", fmt.Errorf("could not determine DMS config directory")
}
return filepath.Join(dir, "wayland.json"), nil
}
// LoadConfig reads the wayland config from disk, falling back to DefaultConfig.
func LoadConfig() Config {
path, err := getConfigPath()
if err != nil {
return DefaultConfig()
}
data, err := os.ReadFile(path)
if err != nil {
return DefaultConfig()
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return DefaultConfig()
}
// Ensure defaults are set for missing fields (keep in sync with DefaultConfig)
def := DefaultConfig()
if cfg.HighTemp == 0 {
cfg.HighTemp = def.HighTemp
}
if cfg.LowTemp == 0 {
cfg.LowTemp = def.LowTemp
}
if cfg.Gamma == 0 {
cfg.Gamma = def.Gamma
}
if cfg.Contrast == 0 {
cfg.Contrast = def.Contrast
}
if cfg.ElevationTwilight == 0 {
cfg.ElevationTwilight = def.ElevationTwilight
}
if cfg.ElevationDaylight == 0 {
cfg.ElevationDaylight = def.ElevationDaylight
}
if cfg.Outputs == nil {
cfg.Outputs = []string{}
}
if cfg.ICCProfiles == nil {
cfg.ICCProfiles = make(map[string]string)
}
if cfg.OutputTemps == nil {
cfg.OutputTemps = make(map[string]int)
}
return cfg
}
// SaveConfig writes the wayland config to disk.
func SaveConfig(cfg Config) error {
path, err := getConfigPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
// SaveICCConfig stores the ICC profiles and the per-output temperatures, leaving
// every other field of the file as it is on disk.
//
// The night light fields are owned by the shell: the daemon only reads them at
// boot and writes them in memory when the shell calls setEnabled/setTemperature,
// so writing its copy back would persist whatever the night light happened to be
// doing when an ICC change was made (for example `"Enabled": true` while the
// user had since turned it off), and the shell only sends setEnabled when the
// mode is on, so the state would silently come back after a session restart.
func SaveICCConfig(iccProfiles map[string]string, outputTemps map[string]int) error {
cfg := LoadConfig()
cfg.ICCProfiles = iccProfiles
cfg.OutputTemps = outputTemps
return SaveConfig(cfg)
}
func (c *Config) Validate() error {
if c.LowTemp < 1000 || c.LowTemp > 10000 {
return errdefs.ErrInvalidTemperature
@@ -223,5 +384,36 @@ func stateChanged(old, new *State) bool {
if old.SunPosition != new.SunPosition {
return true
}
return iccStateChanged(old, new)
}
// iccStateChanged reports whether the ICC part of the state (what the Display
// Config card renders) differs. Without it, applying or removing a profile and
// setting a per-output temperature are the only things that changed, and the
// notifier would treat the state as unchanged and never push it.
func iccStateChanged(old, new *State) bool {
if len(old.ICCProfiles) != len(new.ICCProfiles) || len(old.OutputTemps) != len(new.OutputTemps) {
return true
}
if !slices.Equal(old.Outputs, new.Outputs) {
return true
}
for name, temp := range new.OutputTemps {
if oldTemp, ok := old.OutputTemps[name]; !ok || oldTemp != temp {
return true
}
}
for name, status := range new.ICCProfiles {
prev, ok := old.ICCProfiles[name]
if !ok || prev == nil || status == nil {
if prev != status {
return true
}
continue
}
if *prev != *status {
return true
}
}
return false
}
+115
View File
@@ -3,6 +3,8 @@ package wayland
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestConfigValidate(t *testing.T) {
@@ -268,6 +270,12 @@ func TestStateChanged(t *testing.T) {
new: baseState,
wantChanged: true,
},
{
name: "output list changed",
old: baseState,
new: func() *State { st := *baseState; st.Outputs = []string{"DP-1"}; return &st }(),
wantChanged: true,
},
{
name: "nil_new",
old: baseState,
@@ -325,6 +333,80 @@ func TestStateChanged(t *testing.T) {
},
wantChanged: true,
},
{
// Applying or removing a profile changes nothing else, so the push
// that carries it to the settings page must not be suppressed.
name: "profile_applied",
old: baseState,
new: &State{
CurrentTemp: baseState.CurrentTemp,
NextTransition: baseState.NextTransition,
SunriseTime: baseState.SunriseTime,
SunsetTime: baseState.SunsetTime,
IsDay: baseState.IsDay,
Config: baseState.Config,
ICCProfiles: map[string]*ICCStatus{
"DP-1": {Path: "/tmp/dp1.icc", Active: true},
},
},
wantChanged: true,
},
{
name: "profile_removed",
old: &State{
CurrentTemp: baseState.CurrentTemp,
NextTransition: baseState.NextTransition,
SunriseTime: baseState.SunriseTime,
SunsetTime: baseState.SunsetTime,
IsDay: baseState.IsDay,
Config: baseState.Config,
ICCProfiles: map[string]*ICCStatus{
"DP-1": {Path: "/tmp/dp1.icc", Active: true},
},
},
new: baseState,
wantChanged: true,
},
{
name: "profile_description_changed",
old: &State{
Config: baseState.Config,
ICCProfiles: map[string]*ICCStatus{"DP-1": {Path: "/tmp/dp1.icc", Description: "Old"}},
},
new: &State{
Config: baseState.Config,
ICCProfiles: map[string]*ICCStatus{"DP-1": {Path: "/tmp/dp1.icc", Description: "New"}},
},
wantChanged: true,
},
{
name: "output_temp_changed",
old: baseState,
new: &State{
CurrentTemp: baseState.CurrentTemp,
NextTransition: baseState.NextTransition,
SunriseTime: baseState.SunriseTime,
SunsetTime: baseState.SunsetTime,
IsDay: baseState.IsDay,
Config: baseState.Config,
OutputTemps: map[string]int{"DP-1": 7000},
},
wantChanged: true,
},
{
name: "same_icc_state",
old: &State{
Config: baseState.Config,
ICCProfiles: map[string]*ICCStatus{"DP-1": {Path: "/tmp/dp1.icc", Active: true}},
OutputTemps: map[string]int{"DP-1": 7000},
},
new: &State{
Config: baseState.Config,
ICCProfiles: map[string]*ICCStatus{"DP-1": {Path: "/tmp/dp1.icc", Active: true}},
OutputTemps: map[string]int{"DP-1": 7000},
},
wantChanged: false,
},
}
for _, tt := range tests {
@@ -336,3 +418,36 @@ func TestStateChanged(t *testing.T) {
})
}
}
// The night light fields in wayland.json belong to the shell: the daemon reads
// them at boot and the shell pushes changes through IPC, so an ICC change must
// not write the daemon's copy of them back to disk. Otherwise applying a profile
// while the night light happens to be on persists "Enabled": true, and the mode
// comes back after a session restart with no way to resync from the UI.
func TestSaveICCConfigKeepsNightLightFields(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
onDisk := Config{
Enabled: true,
LowTemp: 3000,
HighTemp: 6000,
Gamma: 1.1,
Contrast: 1.0,
Outputs: []string{},
}
if err := SaveConfig(onDisk); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
if err := SaveICCConfig(map[string]string{"DP-1": "/tmp/dp1.icc"}, map[string]int{"DP-1": 7000}); err != nil {
t.Fatalf("SaveICCConfig: %v", err)
}
got := LoadConfig()
assert.True(t, got.Enabled, "the night light state on disk must survive an ICC write")
assert.Equal(t, 3000, got.LowTemp)
assert.Equal(t, 6000, got.HighTemp)
assert.Equal(t, 1.1, got.Gamma)
assert.Equal(t, map[string]string{"DP-1": "/tmp/dp1.icc"}, got.ICCProfiles)
assert.Equal(t, map[string]int{"DP-1": 7000}, got.OutputTemps)
}
@@ -207,7 +207,9 @@ FocusScope {
visible: active
focus: active
sourceComponent: DisplayConfigTab {}
sourceComponent: DisplayConfigTab {
parentModal: root.parentModal
}
onActiveChanged: {
if (active && item)
@@ -0,0 +1,239 @@
import QtQuick
import qs.Common
import qs.Modals.Common
import qs.Services
import qs.Widgets
DankModal {
id: root
layerNamespace: "dms:icc-profile-info"
keepPopoutsOpen: true
property string outputName: ""
readonly property var profile: outputName !== "" ? (ICCService.status[outputName] ?? null) : null
function showProfile(output) {
outputName = output;
open();
}
function toneCurveText(p) {
if (!p)
return "";
const kind = p.trcKind;
if (kind === "gamma")
return I18n.tr("Gamma", "Gamma") + " " + (p.trcGamma ?? 0).toFixed(2);
if (kind === "table")
return I18n.tr("Table", "Tone curve stored as a lookup table") + " · " + p.trcEntries;
if (kind === "identity")
return I18n.tr("Identity", "Tone curve that leaves the signal unchanged");
if (kind === "mixed")
return I18n.tr("Mixed", "Tone curves that differ between channels");
return "";
}
function whitePointText(p) {
if (!p)
return "";
if (p.whitePointName && p.whitePointCCT)
return p.whitePointName + " · " + p.whitePointCCT + "K";
if (p.whitePointCCT)
return p.whitePointCCT + "K";
if (p.whitePointX !== undefined)
return p.whitePointX.toFixed(4) + ", " + p.whitePointY.toFixed(4);
return "";
}
function vcgtText(p) {
if (!p)
return "";
return p.hasVCGT ? (p.vcgtChannels + " × " + p.vcgtEntries) : I18n.tr("No", "No");
}
function sizeText(p) {
if (!p || !p.size)
return "";
return p.size < 1024 ? p.size + " B" : (p.size / 1024).toFixed(1) + " KB";
}
function modifiedText(p) {
if (!p || !p.modified)
return "";
return Qt.formatDateTime(new Date(p.modified * 1000), "yyyy-MM-dd HH:mm");
}
readonly property var details: {
const p = profile;
if (!p)
return [];
return [{
"label": I18n.tr("Version", "Version"),
"value": p.version || ""
}, {
"label": I18n.tr("Class", "ICC profile class, e.g. monitor, scanner, printer"),
"value": p.class || ""
}, {
"label": I18n.tr("Color space", "ICC profile color space"),
"value": p.colorSpace || ""
}, {
"label": I18n.tr("Tone curve", "How the ICC profile stores its tone reproduction curves"),
"value": toneCurveText(p)
}, {
"label": I18n.tr("Video card gamma table", "Video card gamma table (vcgt) carried by the profile"),
"value": vcgtText(p)
}, {
"label": I18n.tr("White point", "White point the ICC profile was produced for"),
"value": whitePointText(p)
}, {
"label": I18n.tr("Active", "Active"),
"value": p.active ? I18n.tr("Yes", "Yes") : I18n.tr("No", "No")
}, {
"label": I18n.tr("Size", "Size"),
"value": sizeText(p)
}, {
"label": I18n.tr("Modified", "Modified"),
"value": modifiedText(p)
}];
}
modalWidth: 580
modalHeight: 520
onBackgroundClicked: close()
onVisibleChanged: {
if (!visible)
outputName = "";
}
content: Component {
Item {
anchors.fill: parent
Column {
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
Column {
width: parent.width - 40
spacing: Theme.spacingXS
StyledText {
text: root.profile?.description || I18n.tr("Color Profile", "Display Config output card label for the per-monitor ICC profile row")
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
}
StyledText {
text: root.outputName
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
width: parent.width
elide: Text.ElideRight
}
}
DankActionButton {
iconName: "close"
iconSize: Theme.iconSize - 4
iconColor: Theme.surfaceText
onClicked: root.close()
}
}
StyledRect {
width: parent.width
height: parent.height - 120
radius: Theme.cornerRadius
color: Theme.surfaceHover
border.color: Theme.outlineStrong
border.width: 1
clip: true
DankFlickable {
anchors.fill: parent
anchors.margins: Theme.spacingM
contentHeight: detailColumn.implicitHeight
Column {
id: detailColumn
width: parent.width
spacing: Theme.spacingS
Repeater {
model: root.details
delegate: Column {
required property var modelData
width: detailColumn.width
spacing: 2
StyledText {
text: modelData.label
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
elide: Text.ElideRight
}
StyledText {
text: modelData.value !== "" ? modelData.value : "—"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
width: parent.width
elide: Text.ElideMiddle
}
}
}
StyledText {
visible: root.details.length === 0
text: I18n.tr("No information available", "No information available")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceTextMedium
}
StyledText {
text: I18n.tr("Path", "Path")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
width: parent.width
topPadding: Theme.spacingS
}
StyledText {
text: root.profile?.path || "—"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
elide: Text.ElideMiddle
}
}
}
}
Item {
width: parent.width
height: 40
DankButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("Close", "Close")
onClicked: root.close()
}
}
}
}
}
}
@@ -9,6 +9,9 @@ StyledRect {
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
signal requestICCBrowse(string outputName)
signal requestICCInfo(string outputName)
required property string outputName
required property var outputData
property bool isConnected: outputData?.connected ?? false
@@ -402,5 +405,218 @@ StyledRect {
item.outputData = root.outputData;
}
}
// ICC Color Profile row
Rectangle {
width: parent.width
height: 1
color: Theme.withAlpha(Theme.outline, 0.15)
visible: iccProfileRow.visible
}
Row {
id: iccProfileRow
width: parent.width
spacing: Theme.spacingS
visible: root.isConnected && !root.isDisabled && ICCService.outputNames.indexOf(root.outputName) !== -1
property var iccInfo: ICCService.status[root.outputName]
DankIcon {
name: "palette"
size: 18
color: iccProfileRow.iccInfo ? Theme.primary : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - 18 - Theme.spacingS - iccBrowseButton.width - Theme.spacingS - (iccInfoButton.visible ? iccInfoButton.width + Theme.spacingS : 0) - (iccRemoveButton.visible ? iccRemoveButton.width + Theme.spacingS : 0)
spacing: 1
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Color Profile", "Display Config output card label for the per-monitor ICC profile row")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
elide: Text.ElideRight
}
Row {
spacing: Theme.spacingXS
width: parent.width
Rectangle {
width: 6
height: 6
radius: 3
color: iccProfileRow.iccInfo ? Theme.success : Theme.withAlpha(Theme.outline, 0.5)
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: {
if (!iccProfileRow.iccInfo)
return I18n.tr("No profile", "Display Config output card ICC row when the output has no profile applied");
const info = iccProfileRow.iccInfo;
return info.description || info.path || I18n.tr("Active", "Active");
}
font.pixelSize: Theme.fontSizeSmall
color: iccProfileRow.iccInfo ? Theme.success : Theme.surfaceVariantText
width: parent.width - 6 - Theme.spacingXS
elide: Text.ElideMiddle
}
}
}
DankButton {
id: iccBrowseButton
text: I18n.tr("Browse", "Browse")
iconName: "folder_open"
buttonHeight: 30
horizontalPadding: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
onClicked: root.requestICCBrowse(root.outputName)
}
DankButton {
id: iccInfoButton
text: ""
iconName: "info"
buttonHeight: 30
horizontalPadding: Theme.spacingXS
backgroundColor: "transparent"
textColor: Theme.surfaceText
visible: iccProfileRow.iccInfo !== undefined
anchors.verticalCenter: parent.verticalCenter
onClicked: root.requestICCInfo(root.outputName)
}
DankButton {
id: iccRemoveButton
text: ""
iconName: "close"
buttonHeight: 30
horizontalPadding: Theme.spacingXS
backgroundColor: "transparent"
textColor: Theme.error
visible: iccProfileRow.iccInfo !== undefined
anchors.verticalCenter: parent.verticalCenter
onClicked: ICCService.removeICC(root.outputName)
}
}
// Per-output color temperature slider
Row {
id: colorTempRow
width: parent.width
spacing: Theme.spacingS
visible: root.isConnected && !root.isDisabled && ICCService.outputNames.indexOf(root.outputName) !== -1
leftPadding: 0
topPadding: Theme.spacingS
// 0 is "no override": the daemon only publishes an entry for an
// output that has one, and the night light temperature is not this
// value, so a fallback of 7000K would claim a setting nobody made.
property int currentTemp: ICCService.outputTemps[root.outputName] !== undefined ? ICCService.outputTemps[root.outputName] : 0
property bool editing: false
onCurrentTempChanged: {
if (editing)
return
tempSlider.value = currentTemp === 0 ? 7000 : currentTemp
}
DankIcon {
name: "thermostat"
size: 18
color: colorTempRow.currentTemp !== 0 ? Theme.primary : Theme.surfaceVariantText
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - 18 - Theme.spacingS - tempLabel.width - Theme.spacingS - tempResetButton.width - Theme.spacingS
spacing: 1
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Color Temperature", "Color Temperature")
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
text: colorTempRow.currentTemp === 0 ? I18n.tr("Default", "Default") : (colorTempRow.currentTemp + "K")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
StyledText {
id: tempLabel
text: colorTempRow.editing ? (Math.round(tempSlider.value) + "K") : (colorTempRow.currentTemp === 0 ? I18n.tr("Default", "Default") : (colorTempRow.currentTemp + "K"))
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
color: colorTempRow.currentTemp !== 0 ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
// The slider cannot produce the "no override" value (its range starts
// at 3000K), so this is the only way back to "Default" from the UI.
// It keeps its slot instead of appearing on demand: the row sits in
// the same column as the slider below it, so a height change while
// the value is dragged would move the slider under the pointer.
DankButton {
id: tempResetButton
text: ""
iconName: "close"
buttonHeight: 30
horizontalPadding: Theme.spacingXS
backgroundColor: "transparent"
textColor: Theme.error
enabled: colorTempRow.currentTemp !== 0
opacity: colorTempRow.currentTemp !== 0 ? 1 : 0
anchors.verticalCenter: parent.verticalCenter
onClicked: ICCService.setOutputTemp(root.outputName, 0)
}
}
// Slider row (appears below the temp label when visible)
Item {
id: tempSliderRow
width: parent.width - (18 + Theme.spacingS) - Theme.spacingS
height: 48
visible: colorTempRow.visible
x: 18 + Theme.spacingS
DankSlider {
id: tempSlider
width: parent.width
anchors.verticalCenter: parent.verticalCenter
minimum: 3000
maximum: 10000
step: 100
value: colorTempRow.currentTemp === 0 ? 7000 : colorTempRow.currentTemp
showValue: true
unit: "K"
// Scroll-to-change assigns value and emits sliderValueChanged
// without sliderDragFinished, so a wheel change would never be
// sent and would freeze the label on the scrolled value.
wheelEnabled: false
onSliderValueChanged: function (newValue) {
// Keep the label binding (line above) intact: assigning
// tempLabel.text here would freeze it after the first drag.
colorTempRow.editing = true
}
onSliderDragFinished: function (finalValue) {
ICCService.setOutputTemp(root.outputName, finalValue)
colorTempRow.editing = false
}
}
}
}
}
@@ -1,6 +1,7 @@
import QtQuick
import qs.Common
import qs.Modals
import qs.Modals.FileBrowser
import qs.Services
import qs.Widgets
import qs.Modules.Settings.DisplayConfig
@@ -11,6 +12,9 @@ Item {
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
property var parentModal: null
property string pendingICCOutput: ""
property string selectedProfileId: {
const id = SessionData.activeDisplayProfile[CompositorService.compositor] || "";
if (!SettingsData.displayProfileAutoSelect) {
@@ -613,6 +617,11 @@ Item {
required property string modelData
outputName: modelData
outputData: DisplayConfigState.allOutputs[modelData]
onRequestICCBrowse: name => {
pendingICCOutput = name;
iccFileBrowser.open();
}
onRequestICCInfo: name => iccInfoModal.showProfile(name)
}
}
}
@@ -647,6 +656,25 @@ Item {
}
}
ICCProfileInfoModal {
id: iccInfoModal
}
FileBrowserModal {
id: iccFileBrowser
parentModal: root.parentModal || null
browserTitle: I18n.tr("Select ICC Profile", "ICC profile file browser title")
browserIcon: "palette"
browserType: "icc"
showHiddenFiles: false
fileExtensions: ["*.icc", "*.icm"]
onFileSelected: path => {
if (pendingICCOutput) {
ICCService.applyICC(pendingICCOutput, path)
}
}
}
DisplayConfirmationModal {
id: confirmationModal
onConfirmed: DisplayConfigState.confirmChanges(root.selectedProfileId)
+106
View File
@@ -0,0 +1,106 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Services
Singleton {
id: root
property var status: ({}) // outputName -> ICCStatus {path, description, version, colorSpace, hasVCGT, active}
property var outputNames: [] // list of all available output names
property var outputTemps: ({}) // outputName -> color temperature (K)
// statusChanged signal is auto-generated by the status property
Connections {
target: DMSService
function onCapabilitiesReceived() {
requestStatus()
}
function onConnectionStateChanged() {
if (DMSService.isConnected) {
requestStatus()
return
}
status = ({})
outputNames = []
outputTemps = ({})
}
function onGammaStateUpdate(data) {
if (!data)
return
if (data.iccProfiles)
status = data.iccProfiles
if (data.outputTemps)
outputTemps = data.outputTemps
if (data.outputs)
outputNames = data.outputs
}
}
Component.onCompleted: {
if (DMSService.dmsAvailable) {
requestStatus()
}
}
function requestStatus() {
if (!DMSService.isConnected)
return
DMSService.sendRequest("wayland.icc.getStatus", null, response => {
if (!response.result)
return
status = response.result.profiles || ({})
outputNames = response.result.outputs || []
})
DMSService.sendRequest("wayland.icc.getTemps", null, response => {
if (response.result)
outputTemps = response.result
})
}
function applyICC(outputName, iccPath) {
DMSService.sendRequest("wayland.icc.apply", {
"output": outputName,
"path": iccPath
}, response => {
if (response.error) {
ToastService.showError(I18n.tr("ICC Error", "Toast title when an ICC IPC request fails"), response.error)
return
}
ToastService.showInfo(I18n.tr("ICC profile applied to %1", "Toast confirming an ICC profile was applied to an output").arg(outputName))
requestStatus()
})
}
function removeICC(outputName) {
DMSService.sendRequest("wayland.icc.remove", {
"output": outputName
}, response => {
if (response.error) {
ToastService.showError(I18n.tr("ICC Error", "Toast title when an ICC IPC request fails"), response.error)
return
}
ToastService.showInfo(I18n.tr("ICC profile removed from %1", "Toast confirming an ICC profile was removed from an output").arg(outputName))
requestStatus()
})
}
function setOutputTemp(outputName, temp) {
DMSService.sendRequest("wayland.icc.setTemp", {
"output": outputName,
"temp": temp
}, response => {
if (response.error) {
ToastService.showError(I18n.tr("Color Temperature Error", "Toast title when setting a per-output color temperature fails"), response.error)
return
}
requestStatus()
})
}
}