From 4064dec333d78e872dfc6b313aaa89b05327f1f7 Mon Sep 17 00:00:00 2001 From: KIDult2226 <22439293@life.hkbu.edu.hk> Date: Sun, 13 Sep 2026 13:33:15 +0800 Subject: [PATCH] feat(icc): show the profile details in Display Config The profile row only showed the description, so there was no way to tell which profile a display actually has applied (whitepoint, curve type, file provenance) without leaving the settings page. - `ICCStatus` carries the descriptive metadata: class, tone-curve kind (plus gamma or table size), vcgt channels/entries, white point chromaticity with a derived CCT and standard illuminant name, file size and mtime. - `internal/icc` gains `WhitePointXY`, `WhitePointCCT` (McCamy), `WhitePointName` (D50/D65) and `TRCKind`, all covered by tests. - Display Config's Color Profile row gets an info button that opens `ICCProfileInfoModal` with those fields and the profile path, alongside the existing browse/remove actions. - `dms icc info` reports the white point as name/xy/CCT instead of raw XYZ. --- core/cmd/dms/commands_icc.go | 17 +- core/internal/icc/description_test.go | 72 ++++++ core/internal/icc/parser.go | 90 +++++++ core/internal/server/wayland/manager.go | 26 +- core/internal/server/wayland/manager_test.go | 46 ++++ core/internal/server/wayland/types.go | 15 ++ .../DisplayConfig/ICCProfileInfoModal.qml | 239 ++++++++++++++++++ .../Settings/DisplayConfig/OutputCard.qml | 16 +- .../Modules/Settings/DisplayConfigTab.qml | 5 + 9 files changed, 518 insertions(+), 8 deletions(-) create mode 100644 core/internal/icc/description_test.go create mode 100644 quickshell/Modules/Settings/DisplayConfig/ICCProfileInfoModal.qml diff --git a/core/cmd/dms/commands_icc.go b/core/cmd/dms/commands_icc.go index c80356554..2f15696a7 100644 --- a/core/cmd/dms/commands_icc.go +++ b/core/cmd/dms/commands_icc.go @@ -241,8 +241,21 @@ func runICCInfo(cmd *cobra.Command, args []string) { fmt.Println() } - fmt.Printf("White Point: %.4f %.4f %.4f\n", - profile.WhitePoint[0], profile.WhitePoint[1], profile.WhitePoint[2]) + 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) { 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 index 2741ff662..4897f5b76 100644 --- a/core/internal/icc/parser.go +++ b/core/internal/icc/parser.go @@ -618,3 +618,93 @@ func uint16Clamp(v float64) uint16 { } 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/server/wayland/manager.go b/core/internal/server/wayland/manager.go index 879d7f2ab..7ab1b4654 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -1553,11 +1553,27 @@ func (m *Manager) GetICCStatus() map[string]*ICCStatus { Path: out.iccPath, Active: out.iccProfile != nil, } - if out.iccProfile != nil { - status.Description = out.iccProfile.Description - status.Version = out.iccProfile.Version - status.ColorSpace = out.iccProfile.ColorSpace - status.HasVCGT = out.iccProfile.HasVCGT + if profile := out.iccProfile; 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(out.iccPath); err == nil { + status.Size = info.Size() + status.Modified = info.ModTime().Unix() } result[name] = status return true diff --git a/core/internal/server/wayland/manager_test.go b/core/internal/server/wayland/manager_test.go index 79bc4c0d5..5a71e96bb 100644 --- a/core/internal/server/wayland/manager_test.go +++ b/core/internal/server/wayland/manager_test.go @@ -2,6 +2,7 @@ package wayland import ( "errors" + "os" "path/filepath" "sync" "testing" @@ -9,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" + "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" ) @@ -628,3 +630,47 @@ func TestEffectiveTempTarget(t *testing.T) { }) } } + +// 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") + + 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) +} diff --git a/core/internal/server/wayland/types.go b/core/internal/server/wayland/types.go index b567e5dc3..6fb9ce5a8 100644 --- a/core/internal/server/wayland/types.go +++ b/core/internal/server/wayland/types.go @@ -69,6 +69,21 @@ type ICCStatus struct { 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 { diff --git a/quickshell/Modules/Settings/DisplayConfig/ICCProfileInfoModal.qml b/quickshell/Modules/Settings/DisplayConfig/ICCProfileInfoModal.qml new file mode 100644 index 000000000..856e48195 --- /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") + 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 4eea7214a..c4ac1fb61 100644 --- a/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml +++ b/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml @@ -10,6 +10,7 @@ StyledRect { LayoutMirroring.childrenInherit: true signal requestICCBrowse(string outputName) + signal requestICCInfo(string outputName) required property string outputName required property var outputData @@ -429,7 +430,7 @@ StyledRect { } Column { - width: parent.width - 18 - Theme.spacingS - iccBrowseButton.width - Theme.spacingS - (iccRemoveButton.visible ? iccRemoveButton.width + Theme.spacingS : 0) + 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 @@ -479,6 +480,19 @@ StyledRect { 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: "" diff --git a/quickshell/Modules/Settings/DisplayConfigTab.qml b/quickshell/Modules/Settings/DisplayConfigTab.qml index 5406f877c..9e13b1ef3 100644 --- a/quickshell/Modules/Settings/DisplayConfigTab.qml +++ b/quickshell/Modules/Settings/DisplayConfigTab.qml @@ -621,6 +621,7 @@ Item { pendingICCOutput = name; iccFileBrowser.open(); } + onRequestICCInfo: name => iccInfoModal.showProfile(name) } } } @@ -655,6 +656,10 @@ Item { } } + ICCProfileInfoModal { + id: iccInfoModal + } + FileBrowserModal { id: iccFileBrowser parentModal: root.parentModal || null