diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 139d23329..512640358 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -773,6 +773,7 @@ func getCommonCommands() []*cobra.Command { registryCmd, dank16Cmd, brightnessCmd, + iccCmd, dpmsCmd, keybindsCmd, greeterCmd, diff --git a/core/cmd/dms/commands_icc.go b/core/cmd/dms/commands_icc.go new file mode 100644 index 000000000..c5751982c --- /dev/null +++ b/core/cmd/dms/commands_icc.go @@ -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 ", + Short: "Show details of an ICC profile", + Args: cobra.ExactArgs(1), + Run: runICCInfo, +} + +var iccApplyCmd = &cobra.Command{ + Use: "apply ", + Short: "Apply ICC profile to a specific output", + Args: cobra.ExactArgs(2), + Run: runICCApply, +} + +var iccRemoveCmd = &cobra.Command{ + Use: "remove ", + 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 ", + 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)) + } +} diff --git a/core/cmd/dms/commands_icc_test.go b/core/cmd/dms/commands_icc_test.go new file mode 100644 index 000000000..7314556f5 --- /dev/null +++ b/core/cmd/dms/commands_icc_test.go @@ -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") + } +} diff --git a/core/internal/icc/description_test.go b/core/internal/icc/description_test.go new file mode 100644 index 000000000..de2b832e7 --- /dev/null +++ b/core/internal/icc/description_test.go @@ -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) + } + }) + } +} diff --git a/core/internal/icc/parser.go b/core/internal/icc/parser.go new file mode 100644 index 000000000..cab741d7a --- /dev/null +++ b/core/internal/icc/parser.go @@ -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 + } +} diff --git a/core/internal/icc/parser_test.go b/core/internal/icc/parser_test.go new file mode 100644 index 000000000..06081fe0a --- /dev/null +++ b/core/internal/icc/parser_test.go @@ -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) + } + }) + } +} diff --git a/core/internal/server/server.go b/core/internal/server/server.go index d5bfbf627..f12987c34 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -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) diff --git a/core/internal/server/wayland/gamma.go b/core/internal/server/wayland/gamma.go index e12bb248c..b7ef43631 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,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)) +} diff --git a/core/internal/server/wayland/gamma_test.go b/core/internal/server/wayland/gamma_test.go index aeef14ab0..8134841bf 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,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]) + } + }) +} diff --git a/core/internal/server/wayland/handlers.go b/core/internal/server/wayland/handlers.go index 3f356ce19..2131195fe 100644 --- a/core/internal/server/wayland/handlers.go +++ b/core/internal/server/wayland/handlers.go @@ -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) +} diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index 23e2e3e27..98c893126 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -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() { diff --git a/core/internal/server/wayland/manager_test.go b/core/internal/server/wayland/manager_test.go index b582c0a1e..9d2c9d0d0 100644 --- a/core/internal/server/wayland/manager_test.go +++ b/core/internal/server/wayland/manager_test.go @@ -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") +} diff --git a/core/internal/server/wayland/types.go b/core/internal/server/wayland/types.go index 2696f2544..efaa841e9 100644 --- a/core/internal/server/wayland/types.go +++ b/core/internal/server/wayland/types.go @@ -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 } diff --git a/core/internal/server/wayland/types_test.go b/core/internal/server/wayland/types_test.go index 6e4b70952..f4cc0fc3b 100644 --- a/core/internal/server/wayland/types_test.go +++ b/core/internal/server/wayland/types_test.go @@ -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) +} diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 42c31092f..e9c381305 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -207,7 +207,9 @@ FocusScope { visible: active focus: active - sourceComponent: DisplayConfigTab {} + sourceComponent: DisplayConfigTab { + parentModal: root.parentModal + } onActiveChanged: { if (active && item) diff --git a/quickshell/Modules/Settings/DisplayConfig/ICCProfileInfoModal.qml b/quickshell/Modules/Settings/DisplayConfig/ICCProfileInfoModal.qml new file mode 100644 index 000000000..d6ba704fc --- /dev/null +++ b/quickshell/Modules/Settings/DisplayConfig/ICCProfileInfoModal.qml @@ -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() + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml index 0c779e041..9a7ea4159 100644 --- a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml +++ b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml @@ -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 + } + } + } } } diff --git a/quickshell/Modules/Settings/DisplayConfigTab.qml b/quickshell/Modules/Settings/DisplayConfigTab.qml index e2bb83f6e..9e13b1ef3 100644 --- a/quickshell/Modules/Settings/DisplayConfigTab.qml +++ b/quickshell/Modules/Settings/DisplayConfigTab.qml @@ -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) diff --git a/quickshell/Services/ICCService.qml b/quickshell/Services/ICCService.qml new file mode 100644 index 000000000..9618c46cd --- /dev/null +++ b/quickshell/Services/ICCService.qml @@ -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() + }) + } +}