mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 18:54:01 -04:00
feat(display): per-output ICC color profiles and color temperature (#3388)
* feat(display): per-output ICC color profiles and color temperature Rebuild of #3388 on current master: the previous branch was cut from an older base and reverted unrelated fixes in the gamma stack and the display settings UI. ICC profiles: - core/internal/icc: ICC v2/v4 parser (TRC, LUT8/16 and parametric curves, vcgt, description/version/color space) plus gamma ramp generation, with synthetic coverage and optional vendor-profile tests (ICC_TEST_DIR). - wayland manager: load iccProfiles from the compositor's DMS config dir, build the ramp from the profile when one is set (falling back to the temperature ramp when it is missing or unparsable), and re-apply on output hotplug and after resume. - ICC ramps apply while the night light schedule is disabled; outputs without a profile keep an identity ramp. - desc/mluc offsets are validated before slicing, so a malformed profile returns an error instead of panicking the daemon. Per-output temperature: - outputTemps in the same config; SetOutputTemp accepts 1000-10000K and the Display Config slider exposes 3000-10000K, which matters for displays calibrated at a higher white point (e.g. a 7000K profile). Gamma control lifecycle: - needsControls() now keeps the controls alive for configured ICC profiles and per-output temperatures, so toggling the night light off no longer destroys the controls and drops the ramps. - Re-apply paths clear the per-output dedup state instead of adding a manager-level guard, which would have suppressed contrast-only writes and the post-resume forced resend (#1235), and left a recreated control without a ramp. - Monitor sleep/disconnect still re-establishes controls when an output comes back. IPC / CLI / UI: - wayland.icc.{getStatus,apply,remove,listOutputs,setTemp,getTemps} - dms icc list | info <file> | apply <output> <file> | remove <output> | status - Display Config output card gains a Color Profile row (browse/apply/remove with description, version, color space and active state) and a per-output color temperature slider, backed by Services/ICCService.qml. Config path: - DMSConfigDir() follows the compositor layout (niri/dms, hypr/dms, mango/dms) via the shared compositor detection and utils.XDGConfigHome(), replacing the hardcoded niri path in both the manager and `dms icc`. Tests: TZ=UTC go test ./... (60 packages ok), including red/green coverage for the icc parser bounds checks and needsControls(). * feat(icc): add `dms icc set-temp` and show per-output temps in `status` The per-output temperature override was reachable from the Display Config slider and from `wayland.icc.setTemp` over IPC, but not from the CLI. - `dms icc set-temp <output> <kelvin>` (alias `setTemp`) sets a per-output override, 1000-10000K, and 0 clears it so the output follows the night light schedule again. Range checking matches the daemon so an invalid value fails in the CLI instead of over IPC. - `dms icc status` gains a Temp column (the override in K, or `schedule`), so an override can be verified without opening the settings UI. Verified against a running daemon: `dms icc status` reports 7000K for the three overridden outputs and `schedule` for the remaining one. * feat(icc): treat the per-output temperature as the profile's reference white point The per-output temperature was documented as the white point a profile was produced at, but an output with a profile ignored it entirely: the ramp was generated from the profile and the temperature only applied to outputs without one (or as the fallback when a profile failed to parse). A display calibrated at 7000K therefore got the profile ramp, and enabling the night light had no effect on it at all. - `ProfileRampWithTemp` composes the profile ramp with the ratio between the target temperature ramp and the reference ramp, so the temperature is the white point the profile describes and the night light shifts relative to it. - `applyGamma` passes the night light temperature as the target, and `noTempTarget` when the schedule is disabled, which leaves profiled outputs at their reference white point and drives plain outputs with the neutral ramp. - The re-apply sentinel stays 0, so it cannot collide with an applied temperature. Covered by TestProfileRampWithTemp (reference == target is a no-op, warmer and cooler targets move the expected channels). Also: `dms icc set-temp` help text describes the reference-white-point semantics. * fix(icc): load the configured profiles even when controls already exist Restoring the upstream "output returned, re-establishing controls" branch made the registry handler establish the gamma controls before the startup post runs, and that post returned early on `m.controlsInitialized`, so the configured ICC profiles and per-output temperatures were never attached: every output reported "(none)" and the night light ran with defaults until the user re-applied a profile by hand. - `initializeControlsAndICC` (the startup post) loads the configuration first and only creates the controls when they are missing. - The loading is split into `loadConfiguredICC` / `applyConfiguredICCForOutput` so it can also run per output. - A hotplugged output now gets its configured profile and temperature as soon as its name is known (name handler) or when its control is created, which also makes the "re-applies on hotplug" claim in the description true. - After attaching, the output's dedup state is cleared so the ramp is written. Covered by TestManager_LoadConfiguredICCWhenControlsAlreadyExist and TestManager_AttachConfiguredICCForNamedOutput. * fix(icc): make the per-output temperature an actual target The per-output value was treated as the white point a profile was produced at, which meant a display with a profile never changed when the user set a temperature: the profile ramp was applied as measured and the value only mattered when a night light target existed. Per-display temperatures were therefore unusable on profiled outputs while the night light was disabled, which is the normal state for users who want each monitor at its own value. - `effectiveTempTarget` decides per output: a per-output override wins over the night light schedule, the schedule applies when there is no override, and `noTempTarget` means neither. `0` is "no override", not 0K. - `applyGamma` composes the override on top of the profile ramp, treating the profile as measured at 6500K (D65), and drives outputs without a profile with the same target so both paths agree. - `dms icc set-temp` help text and the Display Config slider description go back to describing an independent per-output temperature. Covered by TestEffectiveTempTarget (override wins, schedule fallback, none) and the updated TestProfileRampWithTemp (7000K cools, 5000K warms relative to the 6500K reference). * 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. * fix(icc): keep the daemon, the state snapshot and the temp slider honest A malformed profile could still take the daemon down, the ICC getters ran off the wayland actor goroutine, the pushed state hid a removal, and the per-output temperature slider dropped wheel changes. - `internal/icc`: the tag table was sized with `uint32` arithmetic, so `tagCount*12` wrapped (`0x15555556*12 == 8`) and a 140-byte profile passed the bounds check, allocated ~4.3 GB and then panicked. `ParseFile` runs on the wayland actor goroutine, which has no `recover()`, so a bad profile persisted in `wayland.json` took the daemon down at startup. The count is now compared against the bytes that are actually left before anything is sized with it. - The manager publishes an ICC snapshot (`iccStatus`/`iccTemps`) whenever the actor changes an output's profile or temperature, and `GetICCStatus`/ `GetOutputTemps` serve that snapshot instead of ranging `m.outputs` and reading fields the actor owns. That removes the data race reported by `go test -race` for `dms icc status` running against a hotplug, and keeps the per-profile `os.Stat` off the night light animation path, where it ran on every `updateStateFromSchedule` (including each 25 K step). - `State.ICCProfiles`/`OutputTemps` drop `omitempty`, so removing the last profile pushes `{}` instead of omitting the key: the QML truthiness guard then clears `ICCService.status` instead of leaving the removed profile on screen with a green "active" dot. - `removeOutputByRegistryName` (extracted from the registry remove handler) deletes the `outputNames`/`outputRegNames` entries along with the output, so a disconnected monitor stops being listed by `dms icc status`/`listOutputs`, and a rebound `wl_output` reusing the released object ID cannot attach the previous monitor's profile from the stale name. - The per-output temperature slider sets `wheelEnabled: false` like the other drag-finished sliders: `DankSlider`'s wheel handler assigns `value` and emits `sliderValueChanged` without `sliderDragFinished`, so scrolling dropped the change, froze the label on the scrolled value and destroyed the value binding. Covered by TestParseBytesTagTableBounds (allocates 4,299,161,600 bytes at parser.go:138 and dies with an unrecovered fatal error on the previous commit), TestManager_ICCStatusServesPublishedSnapshot, TestManager_RemoveOutputByRegistryNamePrunesNames and TestManager_ICCStatusConcurrentPublishAndRead; a temporary copy of the getter test against the previous commit reports DATA RACE and passes here. * fix(icc): keep the attached profile when a gamma control is re-created `setupOutputControls` stored a fresh `outputState` for every output it touched, and `createControls` calls it whenever the controls are rebuilt, so recreating a control dropped the output's `iccPath`/`iccProfile`/`outputTemp` — configured state that does not belong to the control. The path is dormant today because `destroyControls` only runs when nothing is configured (`needsControls()` covers `ICCProfiles`/`OutputTemps`), but the snapshot published after `createControls` would then report whatever the rebuilt state holds instead of the profile the display is configured with. `controlStateFor` reuses the state of a known output and resets only what belongs to the new control: the ramp bookkeeping and the failure budget (rampSize, failed, retryCount, lastFailTime, lastTemp, lastGamma, lastContrast). A control that has not reported gamma_size yet has to receive its ramp again, while the profile and the per-output temperature survive. An output seen for the first time still starts from a clean state. Covered by TestManager_ControlStateReuseKeepsAttachedICC. * fix(icc): keep the config a real snapshot and make the per-output temperature apply Third review round. - `Config` is copied by value all over the manager, but `ICCProfiles` and `OutputTemps` are maps, so every copy aliased the ones the manager writes under `configMutex`. `updateStateFromSchedule` stored such a copy in `m.state` and it was marshalled on the connection writer goroutine while `ApplyICC` stored a profile from the IPC goroutine (`fatal error: concurrent map iteration and map write`, which no `recover()` catches); `SaveConfig` marshalled its copy after the unlock, and `loadConfiguredICC`/`attachConfiguredICC` indexed the maps after releasing the lock. Every copy that outlives the lock is cloned now (`cloneConfig`/`configSnapshot`), so a value copy is a snapshot again. - `SetOutputTemp` never created the gamma controls. On a default install (night light off, gamma 1.0, contrast 1.0, no profile, no override) they do not exist, so the `Range` over `m.outputs` matched nothing: the value was written to `wayland.json`, no ramp was applied, and the settings slider snapped back to "Default" until a daemon restart. It now persists first, creates the controls through `ensureOutputControls` (shared with `ApplyICC`), and pushes the state from the actor, and the published temperatures report the configured value even before an output is known. - A TRC is not a video card gamma ramp: `rTRC`/`gTRC`/`bTRC` describe the display's own transfer function, so a gamma-2.2 profile was written as `t^(1/2.2)` — mid gray 0.5 became 47909/65535 — washing the display out instead of correcting it. `ApplyICC` refuses a profile without `vcgt`, the attach path skips one with a warning, and `GenerateGammaRamp` returns an error instead of falling back to the TRC or to an identity ramp. TRC stays profile metadata (`dms icc info`, the settings dialog). - `icc: applied ICC ramp` was logged at Info on every 25 K animation step; it is Debug now, which is where a per-step message belongs. - `I18n.tr("No information available")` in the profile info modal passes its translator context, like every other string this PR adds. Covered by TestManager_ConfigSnapshotOwnsItsMaps, TestManager_ConfigSnapshotConcurrentMarshal (the previous commit dies with `fatal error: concurrent map iteration and map write`), TestManager_SetOutputTempWithoutControlsPublishesValue (the previous commit reports 0 for the value it just stored), TestGenerateGammaRampRequiresVCGT, and the updated TestProfileRampWithTemp, whose fixture now carries an identity vcgt table instead of relying on an empty profile. * fix(icc): push the state when only the ICC part changed Found while sweeping the same class of problem: `stateChanged` compared only the schedule fields, so a change that touches nothing else — applying or removing a profile, `dms icc set-temp` — was treated as "no change" and the notifier never pushed it. The settings page then only caught up because the UI path calls `requestStatus()` afterwards, and the CLI path did not catch up at all, which is the symptom the omitempty fix was meant to remove. `iccStateChanged` compares the ICC profile status and the per-output temperatures, so those changes reach the subscribers on their own. Covered by the new TestStateChanged cases (profile applied, profile removed, profile description changed, output temp changed, unchanged ICC state); the four change cases report `stateChanged() = false` on the previous commit. * fix(icc): keep the night light state, prune names without a control, use a 0 sentinel Fifth review round. - `SaveICCConfig` replaces `SaveConfig` on the three ICC paths (apply, remove, set-temp). Writing the daemon's whole config persisted the night light fields it happens to hold in memory: with the night light on, applying a profile stored `"Enabled": true`, and since the shell only ever sends `wayland.gamma.setEnabled` when the mode is on, turning it off afterwards (an in-memory change on the daemon side, no write) left that `true` on disk — after log out/in the daemon came up driving the schedule while the UI showed night mode off, with no way to resync short of a toggle. The ICC save now reads the file and rewrites only `iccProfiles`/`outputTemps`, so the fields the shell owns stay as they are. - `removeOutputByRegistryName` resolved the output from `m.outputs`, which only has entries once the gamma controls exist. On a default install (night light off, no profile, no override) no control is ever created, so a disconnected monitor kept its `outputNames`/`outputRegNames` entries for the life of the daemon: `dms icc status`/`listOutputs` listed monitors that were gone, and both maps grew with every dock/undock cycle. The object ID is resolved from `m.outputRegNames` (populated for every `wl_output`), the name entries are dropped before the output state is looked up, and a state that is known without a registry name still falls back to matching its own. - The Display Config temperature row used `7000` as its fallback for an output with no override. `publishICCState` only copies entries with `temp != 0`, so the value is absent rather than `0` for an unconfigured output: the card claimed "7000K" while the daemon drove the night light or neutral, and a user who deliberately set 7000K got the "no override" colouring because the highlight tested `!== 7000`. The sentinel is `0` now, which is what the `Default` label branches and the "is overridden" tests were written against. Covered by TestSaveICCConfigKeepsNightLightFields (the previous commit brings the night light back on after a restart), TestManager_RemoveOutputByRegistryNameWithoutControl (the previous commit keeps an unplugged monitor listed) and the existing TestManager_RemoveOutputByRegistryNamePrunesNames. * fix(icc): validate the CLI arguments and the IPC parameters of the ICC commands Sweep of the two surfaces the review has not looked at yet, so the failures are found here instead of in the next round. - `dms icc apply` copied the profile into the config directory before asking the daemon, and only the *parse* was checked locally. A profile the daemon refuses (no `vcgt` table, non-RGB color space) was therefore left in the profile directory for `dms icc list` to keep offering, and an IPC failure left the copy behind as well. The command now checks the same rules the daemon enforces before copying anything, and removes the copy it just made when the request fails (a file that was already there is left alone). - `dms icc list` marks a profile without a `vcgt` table, so the rule that makes `apply` refuse it is visible where the profiles are listed. - `ApplyICC`/`RemoveICC`/`SetOutputTemp` reach the manager over IPC with whatever the caller sent: an empty (or whitespace-padded) output name used to be persisted as an entry in `wayland.json` that no `wl_output` can ever match, and a relative profile path was stored to be resolved again on the next start, from a different working directory. Both are rejected now, before anything is written. Verified with the built CLI against the previous binary: with a profile that has no vcgt table, `dms icc apply` now exits before touching the config directory (`residual=[]`) where the previous one copied the file and went on to the IPC (`residual=[nogamma.icc]`); with a valid profile and no daemon reachable, the previous one leaves `vcgt.icc` behind and this one does not. Covered by TestManager_RejectsInvalidICCArguments. * fix(icc): apply the user gamma and contrast on profiled outputs * fix(icc): recreate failed controls without the night light and allow clearing a per-output temp * test(icc): cover the recreated control and the cleared temperature override Follow-up ond55d3d1f, which fixed both points without tests: - TestManager_RecreateOutputControlFollowsNeedsControls pins the predicate: with the night light off and nothing configured there is nothing to recreate, and with a configured profile or a per-output temperature the control has to be recreated. Both configured cases return nil on9226251c. - TestManager_SetOutputTempZeroClearsTheOverride pins what the reset button in Display Config does end to end: the stored value and the published entry both disappear, so the row reads "Default" and the output follows the schedule. Also keeps the reset button's slot instead of revealing it on demand. The temperature row shares its column with the slider below it, so the height change between "Default" (no button) and an override moved the slider 14 px under the pointer while it was being dragged; the button is now always laid out and only its opacity and enabled state follow the override, which also lets the label width drop the conditional. * fix(icc): replicate a one-channel vcgt and keep the temp slider in sync * fix(icc): release idle gamma controls and push the output list * fix(icc): dedupe ramps on the effective target * fix(icc): never overwrite a same-named profile in the config dir --------- Co-authored-by: bbedward <[email protected]>
This commit is contained in:
@@ -773,6 +773,7 @@ func getCommonCommands() []*cobra.Command {
|
||||
registryCmd,
|
||||
dank16Cmd,
|
||||
brightnessCmd,
|
||||
iccCmd,
|
||||
dpmsCmd,
|
||||
keybindsCmd,
|
||||
greeterCmd,
|
||||
|
||||
@@ -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 <file>",
|
||||
Short: "Show details of an ICC profile",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runICCInfo,
|
||||
}
|
||||
|
||||
var iccApplyCmd = &cobra.Command{
|
||||
Use: "apply <output> <file>",
|
||||
Short: "Apply ICC profile to a specific output",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runICCApply,
|
||||
}
|
||||
|
||||
var iccRemoveCmd = &cobra.Command{
|
||||
Use: "remove <output>",
|
||||
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 <output> <kelvin>",
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ FocusScope {
|
||||
visible: active
|
||||
focus: active
|
||||
|
||||
sourceComponent: DisplayConfigTab {}
|
||||
sourceComponent: DisplayConfigTab {
|
||||
parentModal: root.parentModal
|
||||
}
|
||||
|
||||
onActiveChanged: {
|
||||
if (active && item)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user