Skip to content

feat(display): per-output ICC color profiles and color temperature - #3388

Merged
bbedward merged 19 commits into
AvengeMedia:masterfrom
KIDult2226:icc-per-output
Sep 15, 2026
Merged

bbedward merged 19 commits into
AvengeMedia:masterfrom
KIDult2226:icc-per-output

Conversation

@KIDult2226

@KIDult2226 KIDult2226 commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Description

Adds per-output ICC color profile support and per-output color temperature to the night light / gamma stack.

ICC profiles

  • New core/internal/icc package: ICC v2/v4 parser (TRC curves, LUT8/LUT16 and parametric curves, vcgt, description/version/color space metadata) with unit tests.
  • The wayland manager loads iccProfiles and outputTemps from the DMS config directory of the running compositor (<config>/<compositor>/dms/wayland.json, for example ~/.config/niri/dms/wayland.json on niri), builds gamma ramps from the parsed profile per output, falls back to temperature ramps when a profile is missing or unparsable, and re-applies after resume.
  • ICC ramps are applied even when the global night light is off (outputs without a profile keep identity/neutral), so a calibrated profile is not silently dropped when the schedule is disabled.
  • The configuration is attached independently of the gamma control lifecycle, and a hotplugged output picks up its profile and temperature as soon as its name is known (or when its control is created).

Per-output temperature

  • outputTemps in the same config; SetOutputTemp accepts 1000-10000K and the Display Config slider exposes the full 3000-10000K range.
  • Per-output temperature is an independent target: it applies whether or not the night light schedule runs, and a per-output value wins over the schedule, so each display can keep its own white point and its own on/off state (0 = no override). It is composed on top of an ICC profile ramp, which is treated as measured at 6500K (D65); outputs without a profile are driven with the same value directly. dms icc set-temp <output> <kelvin> sets it from the CLI and dms icc status reports it.

IPC / CLI

  • wayland.icc.getStatus, wayland.icc.apply, wayland.icc.remove, wayland.icc.listOutputs, wayland.icc.setTemp, wayland.icc.getTemps.
  • dms icc list | info <file> | apply <output> <file> | remove <output> | status | set-temp <output> <kelvin>.

UI

  • Display Config output card gains a Color Profile row (browse/apply/remove with description, version, color space and active state) plus a per-output color temperature slider, backed by quickshell/Services/ICCService.qml.

Included fix

  • LoadConfig() now takes its fallback values from DefaultConfig() and covers Contrast. Validate() rejects Contrast == 0, so night light configs written before that field existed aborted manager construction and took gamma/ICC down entirely (wayland manager not initialized).

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behavior)
  • Refactor / internal cleanup
  • Documentation
  • Other

Related issues

Screenshots / video

Display Config output cards with the new Color Profile row (browse / apply / remove, showing an applied profile with version, color space and active state) and the per-output Color Temp slider set to 7000K on the calibrated display:

Display Config: Color Profile row and per-output color temperature

Checklist

  • My code follows the conventions in CONTRIBUTING.md
  • I have tested my changes locally
  • New user-facing strings are wrapped in I18n.tr() with translator context, reusing existing terms where possible
  • Go changes: ran make fmt, added/updated tests, make test passes (TZ=UTC go test ./... -> 51 ok, 0 fail), and go mod tidy is clean
  • QML changes: ran make lint-qml with no new warnings

make lint-qml could not run in my worktree: it needs the Quickshell tooling VFS and the dank-qml-common submodule, which is not checked out there; it fails identically on unmodified master and lints only 3 entrypoints. i18n checks (check_term_variants.py, check_term_freeze.py) and extract_settings_index.py were run instead.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

Comment thread core/internal/server/wayland/manager.go Outdated
log.Info("gamma: output returned, re-establishing controls")
m.controlsInitialized = true
})
if m.controlsInitialized {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This drops the recovery path for monitor sleep/disconnect. When the last output goes away, the remove handler sets m.controlsInitialized = false (line 270). With the old code the returning output ran addOutputControl and then re-marked controlsInitialized = true so the following gamma_size drove the reapply. Now the if m.controlsInitialized guard is false in exactly that case, so nothing is created and gamma/ICC stays dead until the user manually toggles night light.

Keep the previous behaviour: always post addOutputControl, and set controlsInitialized = true when it was cleared.

Comment thread core/internal/server/wayland/manager.go Outdated
case !m.controlsInitialized:
log.Debugf("applyGamma skipped: controls not initialized")
return
case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This manager-level dedupe doesn't include contrast, and it short-circuits before the per-output loop, so two existing paths break:

  • SetAdjustments(gamma, contrast) with only contrast changed → syncControlstriggerUpdateapplyCurrentTempapplyGamma(temp) with the same temp and gamma → early return. The contrast change is never written to any output.
  • handleResume (line 1119) resets every out.lastTemp = 0 to force a resend after suspend (Gamma settings not applied after wake from sleep [with patch] #1235), then calls applyCurrentTemp. Temp and gamma are unchanged across suspend, so this guard returns before the outputs are touched and the forced resend never happens.

Either include contrast in the comparison and have those paths reset m.lastAppliedTemp, or drop this guard and rely on the existing per-output rampCurrent dedupe, which already covers temp/gamma/contrast.

Comment thread core/internal/server/wayland/manager.go Outdated
} else {
log.Debugf("gamma_size: output %d not found in m.outputs", outputID)
}
m.lastAppliedTemp = 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping out.lastTemp = 0 here (and the matching one in the send-failure path below, line ~936) breaks recovery after a gamma control failure. recreateOutputControl reuses the same outputState, so lastTemp/lastGamma/lastContrast survive the failure. When the new control's gamma_size arrives, failed and rampSize are reset but rampCurrent(temp, gamma, contrast) still returns true, so the output is skipped and the recreated control never receives a ramp — the display stays uncorrected.

m.lastAppliedTemp = 0 only bypasses the manager-level guard; it doesn't clear the per-output state. Keep out.lastTemp = 0 in both places.

sunrise := time.Date(now.Year(), now.Month(), now.Day(),
config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
sunset := time.Date(now.Year(), now.Month(), now.Day(),
config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR reverts three unrelated scheduler fixes that exist on the base commit:

  1. The if !sunset.After(sunrise) { sunset = sunset.Add(24 * time.Hour) } adjustment right below this line is gone, so a manual schedule whose night start is past midnight (e.g. sunrise 07:00, sunset 00:30) now produces a Sunset before Sunrise on the same day.
  2. activeCycle/shiftTimes were deleted along with their call sites in getSunPositionNormal, getDeadlineNormal and updateStateFromSchedule, so early-morning hours no longer map back to yesterday's cycle — the temperature and isDay are wrong between midnight and dawn.
  3. In the location-missing branch below (line 553), m.schedule = sunSchedule{} was removed, so stale times from a previous config keep driving applies.

None of these are related to ICC; they should be restored.

Comment thread core/internal/server/wayland/manager.go Outdated
// non-ICC outputs get identity (no color shift).
if !enabled {
m.applyGamma(neutralTemp)
m.applyGamma(high)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high is the user-configurable HighTemp (SetTemperature, valid down to 1000K), not a neutral point — the removed neutralTemp was a fixed 6500. With night light disabled and HighTemp set to e.g. 5000, every output now gets a permanent 5000K ramp written to it instead of neutral. Use a fixed 6500 here (or DefaultConfig().HighTemp).

Related: syncControls (line 1292) still calls destroyControls() whenever needsControls() is false, so toggling night light off tears the controls down and drops the ICC ramps anyway — which defeats the "ICC applies while night light is off" goal of this PR. needsControls() probably needs to account for configured ICC profiles / per-output temps.

Comment thread core/internal/server/wayland/types.go Outdated
if err != nil {
return "", err
}
return filepath.Join(configDir, "niri", "dms", "wayland.json"), nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

niri is hardcoded, but this dir is compositor-specific in the rest of the codebase — commands_setup.go:275 picks niri/dms, hypr/dms or mango/dms based on the running compositor. On Hyprland/mango/sway this writes ICC and per-output temp config into a niri directory that the rest of DMS never looks at. commands_icc.go:70 has the same hardcoding for the ICC profile dir.

Also worth using utils.XDGConfigHome() here rather than os.UserConfigDir(), so it matches the path resolution used everywhere else in core.

Comment thread core/internal/icc/parser.go Outdated
strOff := binary.BigEndian.Uint32(data[recordStart+8 : recordStart+12])
// String offset is absolute from profile start
strStart := entry.offset + strOff
strEnd := strStart + strLen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both desc branches can panic on a malformed profile, which takes down the dms daemon (ApplyICC parses a user-picked file in-process, and dms icc list parses every file in the config dir).

  • strOff and strLen come straight from the file. strStart is never bounds-checked, and strEnd := strStart + strLen is unchecked uint32 arithmetic. If strStart > len(data) the clamp sets strEnd = len(data), leaving strStart > strEnddata[strStart:strEnd] panics. Same for the desc path at line 266-267 if strLen is large enough to wrap strEnd below strStart.
  • recordStart+12 (line 297-299) is only guarded by entry.size < 16, so a tag with size == 16 at the end of the buffer reads past the slice.

Validate strStart <= strEnd <= len(data) (and recordStart+12 <= len(data)) before slicing, and return an error instead.


onSliderValueChanged: function(newValue) {
colorTempRow.editing = true
tempLabel.text = newValue + "K"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assigning to tempLabel.text imperatively destroys its binding permanently. After the first drag the label is a static string: colorTempRow.editing = false no longer restores it, and it stops tracking ICCService.outputTemps[outputName] for any later change (including one made from the CLI or another surface).

The binding on line 537 already handles both cases via colorTempRow.editing/tempSlider.value, so this line just needs to go.

Suggested change
tempLabel.text = newValue + "K"
colorTempRow.editing = true

Comment on lines +570 to +571
colorTempRow.editing = true
tempLabel.text = newValue + "K"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting the suggestion in my comment above — it should replace both lines, not just line 571 (otherwise editing = true ends up duplicated):

Suggested change
colorTempRow.editing = true
tempLabel.text = newValue + "K"
colorTempRow.editing = true

}
}

DisplayConfirmationModal {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR deletes the identifyConfigured property, the MonitorIdentifyOverlay Loader that followed this block, and the id: monitorCanvas it depends on (line 560). That removes the working "identify monitors" overlay, which is unrelated to ICC — looks like an accidental revert. Same for the Theme.spacingXXS2 changes here and in OutputCard.qml, which swap Theme tokens back for hardcoded values.

@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Claude review

Solid feature work, but it reverts several unrelated fixes in the gamma stack and the display settings UI, and the new dedupe breaks two existing paths — those need to be sorted before merge.

  • Monitor sleep/disconnect no longer re-establishes gamma controls, core/internal/server/wayland/manager.go:229
  • New manager-level dedupe ignores contrast and defeats the post-resume forced resend (Gamma settings not applied after wake from sleep [with patch] #1235), core/internal/server/wayland/manager.go:851
  • out.lastTemp = 0 removed, so a recreated gamma control never gets its ramp resent, core/internal/server/wayland/manager.go:341
  • Scheduler fixes reverted: midnight-crossing manual sunset, activeCycle, stale-schedule reset, core/internal/server/wayland/manager.go:542
  • Night light off now applies user HighTemp instead of neutral 6500, and syncControls still destroys controls when disabled (drops ICC ramps), core/internal/server/wayland/manager.go:815
  • Config path hardcodes niri/ for all compositors, core/internal/server/wayland/types.go:167
  • desc/mluc parsing can panic on a malformed profile and take down the daemon, core/internal/icc/parser.go:301
  • tempLabel.text assignment destroys the binding, label goes stale after the first drag, quickshell/Modules/Settings/DisplayConfig/OutputCard.qml:571
  • MonitorIdentifyOverlay and identifyConfigured deleted, quickshell/Modules/Settings/DisplayConfigTab.qml:672

Checked: full diff against the merge base, gamma manager lifecycle/dedupe paths, ICC parser bounds handling, IPC handlers, and the new QML service and Display Config rows. Model: claude-opus-5.

Rebuild of AvengeMedia#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 (AvengeMedia#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().
@KIDult2226

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (6b50696b) and rebuilt so the diff contains only the ICC / per-output-temperature work. Thanks for the review — I reproduced every point before fixing it.

Reverted upstream fixes (restored)

  • manager.go:229 — the "output returned, re-establishing controls" branch is back: addOutputControl is always posted and controlsInitialized is re-marked so the following gamma_size drives the reapply.
  • manager.go:341out.lastTemp = 0 restored in the gamma_size handler and on the send-failure path.
  • manager.go:542 — restored the midnight-crossing sunset adjustment, activeCycle/shiftTimes (plus their call sites in getSunPositionNormal, getDeadlineNormal, updateStateFromSchedule) and the m.schedule = sunSchedule{} reset.
  • manager.go:815applyGamma(neutralTemp) (fixed 6500) again, not HighTemp.
  • DisplayConfigTab.qml:672identifyConfigured, the MonitorIdentifyOverlay loader and id: monitorCanvas are back; Theme.spacingXXS untouched in both files.

New-code fixes

  • manager.go:851 — the manager-level dedupe is gone; applyGamma uses the per-output rampCurrent check as before, so contrast-only SetAdjustments writes and the post-resume forced resend (Gamma settings not applied after wake from sleep [with patch] #1235) work again. ICC/setTemp changes now clear the affected output's state (out.lastTemp = 0), which the schedule-derived comparison cannot see.
  • needsControls() now returns true for configured ICCProfiles/OutputTemps, so disabling the night light no longer calls destroyControls() and drops the ICC ramps. Covered by TestManager_NeedsControlsCoversICCAndOutputTemps (fails on the old one-liner).
  • types.go:167 / commands_icc.go:70 — both go through DMSConfigDir() / ICCProfilesDir(), which map the running compositor to niri/dms, hypr/dms or mango/dms using the same detection the rest of core uses, and resolve via utils.XDGConfigHome(). dms icc reuses that helper instead of its own copy.
  • parser.go:301desc/mluc offsets and lengths are validated in int arithmetic before slicing (strStart <= strEnd <= len(data), recordStart + 12 <= len(data)); malformed profiles now return an error. TestParseDescriptionMalformed panics on the old code (slice bounds out of range [12:4]) and passes on the new one.
  • OutputCard.qml:571 — the tempLabel.text assignment is gone, so the existing binding keeps tracking the slider and ICCService.outputTemps.
  • i18n: new strings carry translator context, existing terms (Browse, Default, Active, Color Temperature) are reused, and no catalog was re-extracted.

One small addition beyond the review: SettingsContent.qml now passes parentModal into DisplayConfigTab, so the ICC file browser stacks over the settings modal the way WallpaperTab's does.

Verification: TZ=UTC go test ./... (all packages ok), check_term_variants.py clean, extract_settings_index.py produces no diff. make lint-qml fails identically on master in my worktree because the dank-qml-common submodule is not checked out; it lints 3 entrypoints only, so it does not cover these files either way.

Diff vs master: 13 files, +2406/−13. The 13 removed lines are DefaultConfig()LoadConfig(), the single GenerateGammaRamp(...) line replaced by the ICC-aware ramp selection, the needsControls() one-liner, the State struct field realignment, and the DisplayConfigTab instantiation.

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.
…e 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.
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.
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).
@KIDult2226

Copy link
Copy Markdown
Contributor Author

Post-review updates now on the branch (033b96b3), on top of the fixes in the previous comment:

  • dms icc set-temp <output> <kelvin> (alias setTemp) sets a per-output value, and dms icc status reports it, so the override is reachable and checkable without the settings UI.
  • Per-output temperature is an independent target: it applies whether or not the night light schedule runs, and a per-output value wins over the schedule, so every display keeps its own white point and its own on/off state (0 = no override, and the range 3000-10000K is not limited by the night light's). It is composed on top of the profile ramp with the profile treated as measured at 6500K (D65). Decision logic lives in effectiveTempTarget.
  • Config attachment no longer depends on the control lifecycle: restoring the "output returned, re-establishing controls" branch made the registry handler establish the controls before the startup post ran, and that post returned early on controlsInitialized, so the configured profiles/temperatures were never attached (every output reported (none) until re-applied by hand). The startup path now loads the configuration first and only creates the controls when they are missing.
  • Hotplugged outputs pick up their configured profile and temperature as soon as the name is known, or when the control is created — the description claimed this but the code did not do it.

Tests added/updated: TestParseDescriptionMalformed (panics with slice bounds out of range [12:4] before the fix), TestManager_NeedsControlsCoversICCAndOutputTemps, TestProfileRampWithTemp, TestEffectiveTempTarget, TestManager_LoadConfiguredICCWhenControlsAlreadyExist, TestManager_AttachConfiguredICCForNamedOutput.

TZ=UTC go test ./... -> 51 packages ok, 0 fail. Verified on a real niri session with three calibrated outputs and per-output 7000K overrides: the ramps shift relative to 6500K as expected, profiles attach on restart, and no ICC/gamma warnings appear in the journal.

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.
@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

Comment thread core/internal/icc/parser.go Outdated
}

tagCount := binary.BigEndian.Uint32(data[128:132])
tagTableEnd := uint32(132) + tagCount*12

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tagCount comes straight from the file and tagCount*12 is unchecked uint32 arithmetic, so the bounds check can be bypassed by wrapping. With tagCount == 0x15555556, tagCount*12 wraps to 8, tagTableEnd becomes 140, and the check passes for any profile ≥ 140 bytes. Line 138 then does make([]tagEntry, 357913942) (~4 GB) and the loop below panics with slice bounds out of range on the first entry.

That matters because applyConfiguredICCForOutputicc.ParseFile runs on the wayland actor goroutine (m.post from loadConfiguredICC/attachConfiguredICC), and waylandActor has no recover() — unlike the IPC path, which is covered by routeRequestRecovered. A malformed profile persisted in wayland.json therefore takes the whole dms daemon down at startup.

Compute the tag table end in int (or check tagCount against (len(data)-132)/12) before allocating.

Suggested change
tagTableEnd := uint32(132) + tagCount*12
if tagCount > uint32((len(data)-132)/12) {
return nil, fmt.Errorf("icc: profile data too short for %d tag entries", tagCount)
}
tagTableEnd := 132 + int(tagCount)*12
if len(data) < tagTableEnd {

Comment thread core/internal/icc/parser.go Outdated
Comment on lines +132 to +133
tagTableEnd := uint32(132) + tagCount*12
if uint32(len(data)) < tagTableEnd {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting the suggestion in my comment above — it has to replace lines 132-133, otherwise the original if is left behind:

Suggested change
tagTableEnd := uint32(132) + tagCount*12
if uint32(len(data)) < tagTableEnd {
if tagCount > uint32((len(data)-132)/12) {
return nil, fmt.Errorf("icc: profile data too short for %d tag entries", tagCount)
}
tagTableEnd := 132 + int(tagCount)*12
if len(data) < tagTableEnd {

Comment on lines +572 to +582
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"

onSliderValueChanged: function (newValue) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DankSlider has wheelEnabled: true by default, and its wheel handler assigns value and emits sliderValueChanged without ever emitting sliderDragFinished. So scrolling over this slider:

  • never calls ICCService.setOutputTemp, so the temperature change is silently dropped;
  • leaves colorTempRow.editing = true permanently, freezing tempLabel on the slider value;
  • imperatively overwrites tempSlider.value, destroying the colorTempRow.currentTemp binding so the slider stops tracking ICCService.outputTemps afterwards.

Every other drag-finished-only slider in the settings UI (SettingsSliderRow.qml:149, SettingsSliderCard.qml:135, SystemMonitorVariantCard.qml:379) sets wheelEnabled: false for exactly this reason.

Suggested change
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"
onSliderValueChanged: function (newValue) {
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"
wheelEnabled: false
onSliderValueChanged: function (newValue) {

NightTime: times.Night,
IsDay: isDay,
SunPosition: pos,
ICCProfiles: m.GetICCStatus(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetICCStatus()/GetOutputTemps() read outputState.iccPath, iccProfile and outputTemp by ranging m.outputs, but those fields are only ever written on the wayland actor goroutine (loadConfiguredICC/attachConfiguredICC and the m.post bodies of ApplyICC/RemoveICC/SetOutputTemp). These getters are called off-actor:

  • updateStateFromSchedule here runs on the scheduler goroutine (line 777) and on the IPC caller goroutine (lines 1510/1539/1631);
  • handleICCGetStatus/handleICCGetTemps/handleICCListOutputs run on the IPC connection goroutine.

So dms icc status (or the settings page polling) concurrent with a hotplug that runs attachConfiguredICC is an unsynchronised read/write of iccProfile — a data race go test -race will report, and a torn pointer read here dereferences in applyGamma. Before this PR updateStateFromSchedule only touched mutex-guarded config/schedule state.

Either move these fields behind a mutex, or have the actor publish a snapshot (e.g. store the status maps under stateMutex when it mutates them) and have the getters read that.

Separately, GetICCStatus does an os.Stat per profiled output, and it now runs on every updateStateFromSchedule — including every 25 K animation step during a sunrise/sunset transition.

Comment on lines +37 to +38
if (data.iccProfiles)
status = data.iccProfiles

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State.ICCProfiles is tagged json:"iccProfiles,omitempty" (types.go:69) and GetICCStatus() returns an empty map once no output has a profile, so the key is omitted from the pushed state entirely. This truthiness guard then skips the assignment and status keeps the last non-empty value.

Result: dms icc remove DP-1 (or removing the last profile from any other surface) pushes a gamma state update with no iccProfiles, and the Display Config card keeps showing the removed profile with a green "active" dot indefinitely. The UI-initiated path only works because removeICC() follows up with requestStatus().

Check for undefined instead of truthiness so an empty/absent map clears the cache:

Suggested change
if (data.iccProfiles)
status = data.iccProfiles
if (data.iccProfiles !== undefined)
status = data.iccProfiles || ({})

Comment on lines +69 to +70
ColorSpace string `json:"colorSpace"` // e.g., "RGB"
HasVCGT bool `json:"hasVCGT"` // has video card gamma table

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting the suggestion I left on ICCService.qml:37 — my !== undefined version does not fix it, because omitempty drops the key entirely, so it is still undefined. The fix belongs here: without omitempty an empty map serialises as {} and the QML guard (if (data.iccProfiles)) sees a truthy object, so ICCService.status is cleared when the last profile is removed via dms icc remove.

Suggested change
ColorSpace string `json:"colorSpace"` // e.g., "RGB"
HasVCGT bool `json:"hasVCGT"` // has video card gamma table
ICCProfiles map[string]*ICCStatus `json:"iccProfiles"` // outputName -> status
OutputTemps map[string]int `json:"outputTemps"` // outputName -> current temp

}

// ListOutputs returns a list of all output names for ICC assignment.
func (m *Manager) ListOutputs() []string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m.outputNames is stored in the wl_output name handler but never deleted in the SetGlobalRemoveHandler at line 225-258 (which does m.outputs.Delete(foundID)). Since this is the first code that enumerates that map, every disconnected monitor stays in it for the life of the daemon: wayland.icc.listOutputs and getStatus.outputs keep reporting monitors that are gone, so dms icc status grows a stale row per unplug/monitor-sleep cycle.

Add m.outputNames.Delete(foundID) next to m.outputs.Delete(foundID). That also closes the narrower window where a rebound wl_output reuses a released object ID and addOutputControl (line 443) attaches the previous monitor's profile from the stale name.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

Comment thread core/internal/server/wayland/gamma.go Outdated
Comment on lines +183 to +194
if targetTemp <= 0 || targetTemp == baseTemp {
return ramp, nil
}

base := GenerateGammaRamp(size, baseTemp, gamma, contrast)
target := GenerateGammaRamp(size, targetTemp, gamma, contrast)

for i := range ramp.Red {
ramp.Red[i] = scaleRampValue(ramp.Red[i], base.Red[i], target.Red[i])
ramp.Green[i] = scaleRampValue(ramp.Green[i], base.Green[i], target.Green[i])
ramp.Blue[i] = scaleRampValue(ramp.Blue[i], base.Blue[i], target.Blue[i])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user's global gamma/contrast adjustments are silently dropped on any output with an ICC profile.

Two paths:

  • targetTemp <= 0 || targetTemp == baseTemp returns the raw vcgt ramp, with no gamma/contrast applied at all. That's the common case: night light off (applyGamma(noTempTarget)targetTemp == -1), or a per-output temp of exactly 6500.
  • The scaling path cancels them. base[i] = pow(applyContrast(t, contrast), 1/gamma) * 65535 and target[i] = pow(applyContrast(t, contrast) * wp_target, 1/gamma) * 65535, so target[i]/base[i] == wp_target^(1/gamma) — a per-channel constant. contrast cancels exactly, and gamma only survives as an exponent on the temperature tint, never as a ramp adjustment.

Meanwhile needsControls() returns true for Gamma != 1.0 || Contrast != 1.0 and the non-ICC branch in applyGamma (manager.go:1011) does apply them, so with SessionData.displayGamma/displayContrast set to anything but 1.0 the profiled monitor visibly diverges from the rest and the sliders do nothing on it.

Apply gamma/contrast to the vcgt ramp itself (feed applyContrast(t, contrast) into the vcgt resample and raise the result to 1/gamma) before composing the temperature ratio, rather than relying on the ratio to carry them.

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Claude review

One new finding; everything from the previous rounds looks addressed.

  • Global gamma/contrast are silently dropped on outputs with an ICC profile — the raw-vcgt early return never applies them, and in the scaling path the target/base ratio cancels them out, core/internal/server/wayland/gamma.go:183

Checked: net diff against the merge base. ICC parser bounds and overflow handling (tag table, desc/mluc, curv, vcgt), ramp composition and the per-output dedup, actor-goroutine vs mutex discipline for the config and the published ICC snapshot, config load/save round trip and the night-light ownership split, control lifecycle across startup/hotplug/sleep/resume, the IPC handlers and the dms icc CLI, and the QML service, output card rows, file browser wiring and info modal (I18n contexts, DankSlider wheel/drag handling, Dank* wrappers). Could not build or run the Go tests in this environment. Model: claude-opus-5.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

m.publishICCState()
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recreateOutputControl (line 549) still bails on !enabled, but this PR makes gamma controls exist while the night light is off (needsControls() now covers ICCProfiles/OutputTemps). Every other lifecycle path was switched to needsControls()schedulerLoop, handleResume, syncControls, the registry handler — this one was not.

Failure: night light off, DP-1 has an ICC profile. The compositor sends failed on DP-1's gamma control (another client grabbed it, or the compositor dropped it). The failed handler sets out.failed = true, destroys the control and schedules the retry; the retry calls recreateOutputControl, which returns immediately because enabled is false. Nothing else clears failed for an existing outputState (gamma_size only arrives from a new control, controlStateFor only runs while !controlsInitialized), so applyGamma's case out.failed: continue skips that output for the life of the daemon — the calibration is silently gone until the user restarts DMS or toggles night light on. Same for a per-output temp override.

Use the same predicate as the rest of the lifecycle:

Suggested change
func (m *Manager) recreateOutputControl(out *outputState) error {
switch {
case m.connectionDead.Load():
return nil
case !m.needsControls() || !m.controlsInitialized:
return nil

(the m.configMutex.RLock()/enabled block above becomes unused and can go.)

id: tempSlider
width: parent.width
anchors.verticalCenter: parent.verticalCenter
minimum: 3000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-output override can be set from the UI but never cleared from it. 0 is the "no override, follow the schedule" sentinel (SetOutputTemp, effectiveTempTarget, the Default branches in tempLabel and the label above it), but this slider's range starts at 3000 and setOutputTemp is only ever called from onSliderDragFinished, so the UI cannot produce 0.

Failure: on a default install the row reads "Default". The user drags the slider once — say to 6500, just to look at it. wayland.json now has outputTemps: {"DP-1": 6500} permanently, needsControls() is permanently true so gamma controls are created and held on an install that never wanted them, and the row can never show "Default" again. The only way back is dms icc set-temp DP-1 0 from a terminal — the ICC row right above has a remove button for exactly this, the temperature row has none.

Either add a reset affordance next to the label (calling ICCService.setOutputTemp(root.outputName, 0), mirroring iccRemoveButton), or make the bottom of the slider range mean "off".

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude review

Two findings; everything from the previous rounds looks addressed (gamma/contrast on profiled outputs, CLI/IPC validation, night light ownership, name-map pruning, config map cloning, ICC-only state pushes).

  • recreateOutputControl still gates on config.Enabled while every other lifecycle path moved to needsControls(), so a failed gamma control on a profiled output is never recreated when the night light is off, core/internal/server/wayland/manager.go:549
  • The per-output temperature can be set from Display Config but never cleared: the slider range starts at 3000 and 0 is the only "follow the schedule" value, so the Default state is unreachable once the slider is touched, quickshell/Modules/Settings/DisplayConfig/OutputCard.qml:578

Checked: net diff against merge base 6b50696. ICC parser bounds/overflow (tag table, desc/mluc, curv, vcgt) and the vcgt-only contract, ramp composition in ProfileRampWithTemp (whitepoint ratio, contrast input, user gamma) against the plain GenerateGammaRamp path, actor-goroutine vs mutex discipline for the config and the published snapshot, control lifecycle across startup/hotplug/sleep/resume/teardown, config load/save round trip, the IPC handlers and dms icc CLI, and the QML service, output card rows, file browser wiring and info modal (Dank* wrappers, I18n.tr contexts and reuse of existing catalog terms, no catalog edits). Could not build or run the Go tests in this environment. Model: claude-opus-5.

@github-actions

Copy link
Copy Markdown
Contributor

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

…erride

Follow-up on d55d3d1, 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 on 9226251.
- 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.
@KIDult2226

Copy link
Copy Markdown
Contributor Author

Both findings were already fixed on the branch in d55d3d1f while I was working on them, so instead of duplicating that commit I added what it was missing (657ab160):

Regression tests for both fixes

  • TestManager_RecreateOutputControlFollowsNeedsControls: night light off with nothing configured → nothing to recreate; night light still off but the output has a profile (or a per-output temperature) → the control has to be recreated. Both configured cases return nil on 9226251c, which is the out.failed-forever path.
  • TestManager_SetOutputTempZeroClearsTheOverride: the reset button's whole path — SetOutputTemp(output, 0) drops the stored value and the published entry, so the row reads "Default" and the output follows the schedule again.

One layout detail on the reset button

The button is laid out unconditionally now, with opacity/enabled following the override instead of visible. The temperature row shares its column with the slider directly below it, so with conditional visibility the row grows from ~16 px to 30 px the moment a drag sets a value — and the slider moves 14 px down while it is being dragged. Keeping the slot also lets the label width drop the conditional. qmllint --bare is clean.

Verification: TZ=UTC go test ./... -> 51 ok, 0 fail; go test -race ./internal/icc/ ./internal/server/wayland/ ./cmd/dms/ clean; gofmt/go vet clean; check_term_variants.py clean, extract_settings_index.py produces no diff.

enabled: colorTempRow.currentTemp !== 0
opacity: colorTempRow.currentTemp !== 0 ? 1 : 0
anchors.verticalCenter: parent.verticalCenter
onClicked: ICCService.setOutputTemp(root.outputName, 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reset button clears the override but leaves the slider handle where it was dragged.

DankSlider.updateValueFromPosition assigns value = newValue on press/drag (dank-qml-common DankSlider.qml:56), which permanently destroys the declarative binding on line 595. After one drag tempSlider.value is a static number, so:

  • pressing this button sets the override to 0 — the label flips to "Default", the icon/label lose the highlight and the button fades out — while the handle stays at the old temperature, which is exactly the state the button exists to leave;
  • the slider also stops following ICCService.outputTemps, so a later dms icc set-temp DP-1 … (or a change from another surface) is not reflected.

SettingsSliderRow.qml:128 has the same problem and solves it by assigning slider.value in the reset handler. Re-establishing the binding fixes both cases:

Suggested change
onClicked: ICCService.setOutputTemp(root.outputName, 0)
onClicked: {
ICCService.setOutputTemp(root.outputName, 0)
tempSlider.value = Qt.binding(() => colorTempRow.currentTemp === 0 ? 7000 : colorTempRow.currentTemp)
}

Comment thread core/internal/icc/parser.go Outdated
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 channels < 1 || count < 1 || entrySize < 1 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

channels < 3 is accepted here, but the loop below only fills ch < channels && ch < 3 while Red/Green/Blue are all allocated at count entries. A vcgt with channels == 1 (legal in the vcgt spec — one curve meant for all three channels) therefore returns a table whose green and blue arrays are entirely zero.

Nothing downstream catches that: ApplyICC only checks ColorSpace == "RGB" and HasVCGT, and ProfileRampWithTempresampleVCGT happily returns 0 for every position, so the daemon writes a ramp that blanks the green and blue channels — the display goes red and the path is persisted in wayland.json, so it comes back after a restart. The recovery is dms icc remove <output> from a terminal on an unreadable screen.

Either replicate channel 0 into green/blue when channels == 1, or reject anything other than 3 so the profile is refused with an error the way a non-RGB one is.

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude review

Two findings, both minor; the previous rounds look addressed (gamma/contrast on profiled outputs, recreateOutputControl gating on needsControls(), the reset affordance for the per-output temperature, CLI/IPC validation, night-light ownership on save, name-map pruning, config map cloning, ICC-only state pushes).

  • The temperature reset button leaves the slider handle stranded: DankSlider assigns value during a drag, which kills the binding on line 595, so clearing the override never moves the handle and the slider stops tracking ICCService.outputTemps, quickshell/Modules/Settings/DisplayConfig/OutputCard.qml:576
  • parseVCGT accepts channels < 3 and leaves the unfilled channels zeroed, so a 1-channel vcgt is applied as a ramp that blanks green and blue, core/internal/icc/parser.go:436

Checked: net diff against merge base 6b50696. ICC parser bounds and overflow handling (tag table, desc/mluc, curv, vcgt) and the vcgt-only contract, ramp composition in ProfileRampWithTemp against the plain GenerateGammaRamp path, the per-output dedup and effectiveTempTarget, actor-goroutine vs mutex discipline for the config and the published snapshot, control lifecycle across startup/hotplug/sleep/resume/teardown, config load/save round trip, the IPC handlers and dms icc CLI, and the QML service, output card rows, file browser wiring and info modal (Dank* wrappers, I18n.tr contexts against en.json, no catalog edits). Could not build or run the Go tests in this environment. Model: claude-opus-5.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

// 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ICC paths create gamma controls but nothing ever releases them again, so needsControls() and controlsInitialized fall out of sync permanently.

syncControls() — the only caller of destroyControls() — is reached from SetAdjustments and SetEnabled only. ApplyICC (1610) and this call create controls when needsControls() becomes true, but the inverse transition has no path:

  • RemoveICC on the last profiled output, or SetOutputTemp(out, 0) on the last override, deletes the config entry so needsControls() is false again, yet the controls stay up and keep holding zwlr_gamma_control_v1 for every output for the life of the daemon. Before this PR the control was always released once nothing needed it, so another gamma client (gammastep, wlsunset, wl-gammarelay) could take over; now it can't until DMS restarts.
  • Clearing an override on an install that never had controls is worse: this ensureOutputControls() runs unconditionally, so dms icc set-temp DP-1 0 (or the new reset button) creates the controls in order to clear the one thing that justified them, and then keeps them.

Both paths should end with m.syncControls() (or skip ensureOutputControls() when there is nothing left to apply), so the controls are torn down when needsControls() goes false.

return
if (data.iccProfiles)
status = data.iccProfiles
if (data.outputTemps)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

outputNames is never refreshed on a hotplug, so a monitor connected after the shell started gets no ICC row and no temperature slider.

outputNames is only assigned in requestStatus() (line 57), and requestStatus() only runs on capabilitiesReceived, on connect, at Component.onCompleted, and in the applyICC/removeICC/setOutputTemp callbacks. Nothing calls it when the set of outputs changes. The pushed state doesn't help either: the daemon publishes iccProfiles/outputTemps here but not the output list (State has no outputs field — ListOutputs() is only served by wayland.icc.getStatus/listOutputs).

Both new rows in OutputCard.qml gate on ICCService.outputNames.indexOf(root.outputName) !== -1 (lines 421 and 515), so for a display docked mid-session the Color Profile row and the Color Temp slider stay hidden until the shell restarts, or until the user happens to apply/remove a profile on some other output. The daemon side already does the right thing — attachConfiguredICC runs on the new wl_output's name event — it is only the UI that can't see it.

Either add the output list to the published state (publishICCState already runs on every hotplug via attachConfiguredICC/removeOutputByRegistryName) and read it here, or call requestStatus() from this handler when the pushed state names an output that isn't in outputNames yet.

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude review

Two findings; the previous rounds look addressed (the vcgt channel count, the temp slider staying in sync after a drag, recreateOutputControl on needsControls(), gamma/contrast on profiled outputs, CLI/IPC validation, night-light ownership on save, name-map pruning, config map cloning, ICC-only state pushes).

  • Nothing releases the gamma controls once the last profile/override is cleared: syncControls() is only reached from SetAdjustments/SetEnabled, so RemoveICC and SetOutputTemp(out, 0) leave zwlr_gamma_control_v1 held for every output until a restart — and clearing an override even creates the controls first, core/internal/server/wayland/manager.go:1815
  • ICCService.outputNames is only filled by requestStatus() and the pushed state carries no output list, so a monitor docked mid-session never gets the Color Profile row or the Color Temp slider (both gate on outputNames), quickshell/Services/ICCService.qml:39

Checked: net diff against merge base 6b50696. ICC parser bounds/overflow (tag table, desc/mluc, curv, vcgt channel count and entry size) and the vcgt-only contract, ramp composition in ProfileRampWithTemp vs GenerateGammaRamp, effectiveTempTarget and the per-output dedup, actor-goroutine vs mutex discipline for the config and the published ICC snapshot, control lifecycle across startup/hotplug/sleep/resume/teardown, config load/save round trip and SaveICCConfig, the IPC handlers and the dms icc CLI, and the QML service, output card rows, file browser wiring and info modal (Dank* wrappers, I18n.tr contexts, no catalog edits). Could not build or run the Go tests in this environment. Model: claude-opus-5.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

Comment thread core/cmd/dms/commands_icc.go Outdated
if err != nil {
log.Fatalf("Failed to read ICC file: %v", err)
}
if err := os.WriteFile(destPath, data, 0644); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destPath is keyed only on filepath.Base(filePath), so this silently overwrites an existing profile in the config dir that has the same file name.

dms icc apply DP-1 ~/cal/dp1/display.icc then dms icc apply DP-2 ~/cal/dp2/display.icc both resolve to <config>/dms/icc/display.icc: the second copy replaces the first, and wayland.json now has both outputs pointing at that one path. The daemon keeps the profile it already parsed in memory, so nothing looks wrong until the next start, when DP-1 is driven with DP-2's vcgt — a calibrated display quietly gets the wrong ramp, and the original file is gone.

copied is false in that case too, so even the rollback in dropCopiedProfile won't restore it.

Worth either erroring out when destPath exists with different content, or disambiguating the destination name (e.g. suffix on collision) before writing.

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude review

Previous rounds look addressed; one new finding, in the CLI copy path.

  • dms icc apply overwrites a same-named profile already in the config dir, so two displays can end up sharing one file and the wrong ramp after a restart, core/cmd/dms/commands_icc.go:312

Checked: net diff against merge base 6b50696. The two newest commits in detail — releaseIdleControls on RemoveICC/SetOutputTemp (teardown still publishes the snapshot via destroyControls, needsControls() keeps controls alive while anything is configured) and the rampCurrent switch to the effective target (effectiveTempTarget never returns 0, so the forced-resend sentinel still works). Also re-checked: startup attach ordering (registry name handlers post before the startup post, addOutputControl attaches from outputNames), name-map pruning on output removal, actor-goroutine vs mutex discipline for the config and the ICC snapshot, ICC parser bounds/overflow (tag table, desc/mluc, curv, vcgt channels/entry size) and the vcgt-only contract, ramp composition in ProfileRampWithTemp vs GenerateGammaRamp, config load/save round trip and SaveICCConfig leaving the night-light fields alone, IPC handlers and CLI parameter validation, and the QML service, output card rows, slider wheel/drag handling, file browser wiring and info modal (Dank* wrappers, I18n.tr contexts verified against en.json, no catalog edits). Could not build or run the tests in this environment. Model: claude-opus-5.

@bbedward

Copy link
Copy Markdown
Collaborator

/claude review

@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Claude review

No issues found. The findings from the previous rounds all look addressed.

Checked: net diff against merge base. The four newest commits in detail — stageProfile/dropCopiedProfile (in-place reuse, identical-content reuse, numbered sibling, rollback on a rejected apply), releaseIdleControls on RemoveICC/SetOutputTemp (teardown restores compositor gamma and republishes, needsControls() keeps controls while anything is configured, clearing a value nothing holds creates none), the rampCurrent switch to the effective target (every forced-resend site still writes lastTemp = 0 and effectiveTempTarget never returns 0, so the sentinel holds), and the vcgt channel-count handling (1 replicated, 3 read independently, anything else rejected; entry-size and totalBytes bounds hold). Also re-checked: startup attach ordering (addOutputControl posts before the startup post and attaches from outputNames), name-map pruning on output removal, config-map cloning and the actor-vs-mutex split for the published ICC snapshot, State.Outputs plumbed through iccStateChanged into ICCService.outputNames, ICC parser bounds/overflow (tag table, desc/mluc, curv, vcgt), ramp composition in ProfileRampWithTemp vs GenerateGammaRamp, SaveICCConfig leaving the night-light fields on disk alone, the IPC handlers and dms icc CLI validation, and the QML service, output card rows, slider drag/wheel/reset handling, file browser wiring (FileBrowserModal already strips file://) and info modal (Dank* wrappers, I18n.tr contexts, no catalog edits). Could not build or run the tests in this environment. Model: claude-opus-5.

@bbedward
bbedward merged commit e809733 into AvengeMedia:master Sep 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants