From 033b96b38ce5af5f888e3a0645b89c40e35fdbcb Mon Sep 17 00:00:00 2001 From: KIDult2226 <22439293@life.hkbu.edu.hk> Date: Sun, 13 Sep 2026 01:11:34 +0800 Subject: [PATCH] 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). --- core/cmd/dms/commands_icc.go | 16 +++--- core/internal/server/wayland/gamma_test.go | 60 +++++++++++--------- core/internal/server/wayland/manager.go | 39 +++++++------ core/internal/server/wayland/manager_test.go | 23 ++++++++ 4 files changed, 88 insertions(+), 50 deletions(-) diff --git a/core/cmd/dms/commands_icc.go b/core/cmd/dms/commands_icc.go index ea377739e..c80356554 100644 --- a/core/cmd/dms/commands_icc.go +++ b/core/cmd/dms/commands_icc.go @@ -60,13 +60,13 @@ var iccSetTempCmd = &cobra.Command{ Use: "set-temp ", Aliases: []string{"setTemp"}, Short: "Set the color temperature for a specific output", - Long: "Set the reference white point of an output, in kelvin.\n\n" + - "For outputs with an ICC profile this is the white point the profile was\n" + - "produced at: the profile is applied as measured, and the night light\n" + - "shifts from that reference instead of assuming 6500K. Outputs without a\n" + - "profile use it as their color temperature directly.\n\n" + + 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 with a 6500K reference.", + "night light schedule again (6500K while it is disabled).", Args: cobra.ExactArgs(2), Run: runICCSetTemp, } @@ -351,10 +351,10 @@ func runICCSetTemp(cmd *cobra.Command, args []string) { } if temp == 0 { - fmt.Printf("Output '%s' uses a 6500K reference white point again\n", outputName) + fmt.Printf("Output '%s' follows the night light schedule again\n", outputName) return } - fmt.Printf("Set output '%s' reference white point to %dK\n", outputName, temp) + fmt.Printf("Set output '%s' color temperature to %dK\n", outputName, temp) } // fetchICCOutputTemps asks the running server for the per-output overrides. diff --git a/core/internal/server/wayland/gamma_test.go b/core/internal/server/wayland/gamma_test.go index 91ffc6c1b..c627bd38e 100644 --- a/core/internal/server/wayland/gamma_test.go +++ b/core/internal/server/wayland/gamma_test.go @@ -159,9 +159,9 @@ func TestGenerateGammaRamp_ContrastPivotsAtMidGray(t *testing.T) { } } -// The per-output temperature is the white point the ICC profile was produced -// at, so it only shifts the ramp when the night light asks for a different -// temperature than that reference. +// 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 @@ -171,35 +171,27 @@ func TestProfileRampWithTemp(t *testing.T) { identity := GenerateIdentityRamp(size) t.Run("no target keeps the profile as measured", func(t *testing.T) { - for _, target := range []int{noTempTarget, 7000} { - ramp, err := ProfileRampWithTemp(size, profile, 7000, target, 1, 1) - if err != nil { - t.Fatalf("target %d: ProfileRampWithTemp: %v", target, err) - } - if !slices.Equal(identity.Red, ramp.Red) || !slices.Equal(identity.Blue, ramp.Blue) { - t.Fatalf("target %d: profile ramp should be unchanged at its own white point", target) - } - } - }) - - t.Run("warmer target than the reference warms the output", func(t *testing.T) { - ramp, err := ProfileRampWithTemp(size, profile, 7000, 4000, 1, 1) + ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, noTempTarget, 1, 1) if err != nil { t.Fatalf("ProfileRampWithTemp: %v", err) } - if ramp.Red[mid] <= identity.Red[mid] { - t.Errorf("red should be lifted: 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]) + 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("cooler target than the reference cools the output", func(t *testing.T) { - ramp, err := ProfileRampWithTemp(size, profile, 6500, 10000, 1, 1) + 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) } @@ -213,4 +205,20 @@ func TestProfileRampWithTemp(t *testing.T) { 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]) + } + }) } diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index 8eab9da08..879d7f2ab 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -845,6 +845,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 @@ -885,37 +899,30 @@ func (m *Manager) applyGamma(temp int) { case !m.outputStillValid(out): continue } - // Per-output temperature doubles as the white point the profile was - // produced at; without one, the profile is assumed to be at neutral. - outBaseTemp := out.outputTemp - if outBaseTemp == 0 { - outBaseTemp = neutralTemp - } + targetTemp := effectiveTempTarget(out, temp) var ramp GammaRamp if out.iccPath != "" && out.iccProfile != nil { - profileRamp, err := ProfileRampWithTemp(out.rampSize, out.iccProfile, outBaseTemp, temp, gamma, contrast) + // 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 := temp + fallbackTemp := targetTemp if fallbackTemp <= 0 { - fallbackTemp = outBaseTemp + fallbackTemp = neutralTemp } ramp = GenerateGammaRamp(out.rampSize, fallbackTemp, gamma, contrast) } else { ramp = profileRamp - log.Infof("icc: applied ICC ramp to output %d (size=%d, ref=%dK, target=%dK)", out.id, out.rampSize, outBaseTemp, temp) + log.Infof("icc: applied ICC ramp to output %d (size=%d, ref=%dK, target=%dK)", out.id, out.rampSize, neutralTemp, targetTemp) } } else { - // No ICC profile — use per-output temp if set, otherwise the night - // light temperature, otherwise neutral. - outTemp := temp + outTemp := targetTemp if outTemp <= 0 { outTemp = neutralTemp } - if out.outputTemp != 0 { - outTemp = out.outputTemp - } ramp = GenerateGammaRamp(out.rampSize, outTemp, gamma, contrast) } buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6)) diff --git a/core/internal/server/wayland/manager_test.go b/core/internal/server/wayland/manager_test.go index 1760070ab..79bc4c0d5 100644 --- a/core/internal/server/wayland/manager_test.go +++ b/core/internal/server/wayland/manager_test.go @@ -605,3 +605,26 @@ func TestManager_AttachConfiguredICCForNamedOutput(t *testing.T) { 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)) + }) + } +}