diff --git a/core/cmd/dms/commands_icc.go b/core/cmd/dms/commands_icc.go index 97b7aa441..ea377739e 100644 --- a/core/cmd/dms/commands_icc.go +++ b/core/cmd/dms/commands_icc.go @@ -60,10 +60,13 @@ var iccSetTempCmd = &cobra.Command{ Use: "set-temp ", Aliases: []string{"setTemp"}, Short: "Set the color temperature for a specific output", - Long: "Set the color temperature for a specific output, overriding the\n" + - "night light temperature for that output only.\n\n" + - "Accepts 1000-10000K; 0 removes the override so the output follows the\n" + - "night light schedule again.", + 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" + + "Accepts 1000-10000K; 0 removes the override, so the output follows the\n" + + "night light schedule with a 6500K reference.", Args: cobra.ExactArgs(2), Run: runICCSetTemp, } @@ -348,10 +351,10 @@ func runICCSetTemp(cmd *cobra.Command, args []string) { } if temp == 0 { - fmt.Printf("Output '%s' follows the night light temperature again\n", outputName) + fmt.Printf("Output '%s' uses a 6500K reference white point again\n", outputName) return } - fmt.Printf("Set output '%s' color temperature to %dK\n", outputName, temp) + fmt.Printf("Set output '%s' reference white point to %dK\n", outputName, temp) } // fetchICCOutputTemps asks the running server for the per-output overrides. diff --git a/core/internal/server/wayland/gamma.go b/core/internal/server/wayland/gamma.go index e12bb248c..2ece22ef3 100644 --- a/core/internal/server/wayland/gamma.go +++ b/core/internal/server/wayland/gamma.go @@ -2,6 +2,8 @@ package wayland import ( "math" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/icc" ) type GammaRamp struct { @@ -166,3 +168,39 @@ func GenerateIdentityRamp(size uint32) GammaRamp { return ramp } + +// ProfileRampWithTemp builds the gamma ramp for an output that has an ICC +// profile. The profile describes the display at its reference white point +// (baseTemp), so a temperature target is composed as the ratio between the +// target ramp and the base ramp. targetTemp <= 0 means "no target", which +// leaves the profile as measured. +func ProfileRampWithTemp(size uint32, profile *icc.Profile, baseTemp, targetTemp int, gamma, contrast float64) (GammaRamp, error) { + iccRamp, err := icc.GenerateGammaRamp(size, profile) + if err != nil { + return GammaRamp{}, err + } + ramp := GammaRamp{Red: iccRamp.Red, Green: iccRamp.Green, Blue: iccRamp.Blue} + if targetTemp <= 0 || targetTemp == baseTemp { + return ramp, nil + } + + base := GenerateGammaRamp(size, baseTemp, gamma, contrast) + target := GenerateGammaRamp(size, targetTemp, gamma, contrast) + + for i := range ramp.Red { + ramp.Red[i] = scaleRampValue(ramp.Red[i], base.Red[i], target.Red[i]) + ramp.Green[i] = scaleRampValue(ramp.Green[i], base.Green[i], target.Green[i]) + ramp.Blue[i] = scaleRampValue(ramp.Blue[i], base.Blue[i], target.Blue[i]) + } + + return ramp, nil +} + +// scaleRampValue rescales a profile ramp value by the ratio of the target and +// base temperature ramps at the same index. +func scaleRampValue(value, base, target uint16) uint16 { + if base == 0 { + return value + } + return uint16(clamp01(float64(value)*float64(target)/float64(base)/65535.0) * 65535.0) +} diff --git a/core/internal/server/wayland/gamma_test.go b/core/internal/server/wayland/gamma_test.go index aeef14ab0..91ffc6c1b 100644 --- a/core/internal/server/wayland/gamma_test.go +++ b/core/internal/server/wayland/gamma_test.go @@ -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,59 @@ 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]) } } + +// 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. +func TestProfileRampWithTemp(t *testing.T) { + const size = uint32(256) + mid := int(size) / 2 + + // An empty profile yields the identity ramp. + profile := &icc.Profile{} + 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) + 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]) + } + }) + + t.Run("cooler target than the reference cools the output", func(t *testing.T) { + ramp, err := ProfileRampWithTemp(size, profile, 6500, 10000, 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]) + } + }) +} diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index ec1540b02..a5b0c2989 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -27,6 +27,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 @@ -837,11 +842,10 @@ func (m *Manager) applyCurrentTemp(_ string) { low, high := m.config.LowTemp, m.config.HighTemp m.configMutex.RUnlock() - // With the night light disabled the outputs still need their ICC ramp, so - // push a neutral temperature: outputs with a profile get their measured - // ramp, the rest stay at identity. + // 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 } @@ -905,24 +909,34 @@ 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 + } + var ramp GammaRamp if out.iccPath != "" && out.iccProfile != nil { - // ICC profile is active for this output — use ICC ramp - iccRamp, err := icc.GenerateGammaRamp(out.rampSize, out.iccProfile) + profileRamp, err := ProfileRampWithTemp(out.rampSize, out.iccProfile, outBaseTemp, temp, gamma, contrast) if err != nil { log.Warnf("icc: failed to generate ramp for output %d: %v, falling back to temperature", out.id, err) - outTemp := temp - if out.outputTemp != 0 { - outTemp = out.outputTemp + fallbackTemp := temp + if fallbackTemp <= 0 { + fallbackTemp = outBaseTemp } - ramp = GenerateGammaRamp(out.rampSize, outTemp, gamma, contrast) + ramp = GenerateGammaRamp(out.rampSize, fallbackTemp, gamma, contrast) } else { - ramp = GammaRamp{Red: iccRamp.Red, Green: iccRamp.Green, Blue: iccRamp.Blue} - log.Infof("icc: applied ICC ramp to output %d (size=%d)", out.id, out.rampSize) + ramp = profileRamp + log.Infof("icc: applied ICC ramp to output %d (size=%d, ref=%dK, target=%dK)", out.id, out.rampSize, outBaseTemp, temp) } } else { - // No ICC profile — use per-output temp if set, otherwise global temp + // No ICC profile — use per-output temp if set, otherwise the night + // light temperature, otherwise neutral. outTemp := temp + if outTemp <= 0 { + outTemp = neutralTemp + } if out.outputTemp != 0 { outTemp = out.outputTemp }