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.
This commit is contained in:
KIDult2226 committed 2026-09-13 13:35:07 +08:00
1 parent 033b96b38c
commit 4064dec333
9 files changed
+518 -8

No files matched your search

+15 -2
View File
@@ -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) {
+72
View File
@@ -0,0 +1,72 @@
package icc
import (
"math"
"testing"
)
func TestWhitePointCCT(t *testing.T) {
cases := []struct {
name string
wp [3]float64
wantCCT int
wantName string
}{
{"D65", [3]float64{0.9505, 1.0, 1.0890}, 6504, "D65"},
{"D50", [3]float64{0.9642, 1.0, 0.8249}, 5003, "D50"},
{"missing white point", [3]float64{}, 0, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := &Profile{WhitePoint: tc.wp}
cct := p.WhitePointCCT()
if tc.wantCCT == 0 {
if cct != 0 {
t.Fatalf("WhitePointCCT() = %d, want 0", cct)
}
} else if math.Abs(float64(cct-tc.wantCCT)) > 150 {
t.Fatalf("WhitePointCCT() = %d, want ~%d", cct, tc.wantCCT)
}
if got := p.WhitePointName(); got != tc.wantName {
t.Fatalf("WhitePointName() = %q, want %q", got, tc.wantName)
}
})
}
}
func TestTRCKind(t *testing.T) {
identity := Curve{Type: CurveIdentity}
gamma22 := Curve{Type: CurveParametric, Gamma: 2.2}
gamma18 := Curve{Type: CurveParametric, Gamma: 1.8}
table := Curve{Type: CurveTable, Entries: make([]uint16, 1024)}
tableShort := Curve{Type: CurveTable, Entries: make([]uint16, 256)}
cases := []struct {
name string
profile Profile
wantKind string
wantGamma float64
wantEntries int
}{
{"identity", Profile{HasTRC: true, TRC: [3]Curve{identity, identity, identity}}, "identity", 0, 0},
{"gamma", Profile{HasTRC: true, TRC: [3]Curve{gamma22, gamma22, gamma22}}, "gamma", 2.2, 0},
{"table", Profile{HasTRC: true, TRC: [3]Curve{table, table, table}}, "table", 0, 1024},
{"mixed types", Profile{HasTRC: true, TRC: [3]Curve{gamma22, table, gamma22}}, "mixed", 0, 0},
{"per-channel gamma", Profile{HasTRC: true, TRC: [3]Curve{gamma22, gamma18, gamma22}}, "mixed", 0, 0},
{"per-channel table size", Profile{HasTRC: true, TRC: [3]Curve{table, tableShort, table}}, "mixed", 0, 0},
{"no TRC", Profile{}, "", 0, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
kind, gamma, entries := tc.profile.TRCKind()
if kind != tc.wantKind || gamma != tc.wantGamma || entries != tc.wantEntries {
t.Fatalf("TRCKind() = (%q, %v, %d), want (%q, %v, %d)",
kind, gamma, entries, tc.wantKind, tc.wantGamma, tc.wantEntries)
}
})
}
}
+90
View File
@@ -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
}
}
+21 -5
View File
@@ -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
@@ -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)
}
+15
View File
@@ -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 {
@@ -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()
}
}
}
}
}
}
@@ -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: ""
@@ -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