diff --git a/core/internal/server/brightness/ddc.go b/core/internal/server/brightness/ddc.go index 9ce1dce0f..693b886f3 100644 --- a/core/internal/server/brightness/ddc.go +++ b/core/internal/server/brightness/ddc.go @@ -122,6 +122,7 @@ func (b *DDCBackend) scanI2CDevicesInternal(force bool) error { } dev.id = id + dev.connector = drmConnectorPattern.ReplaceAllString(connectors[i], "") unread := dev.max == 0 b.devices.Store(id, dev) log.Debugf("found DDC device on i2c-%d", i) @@ -367,6 +368,18 @@ func (b *DDCBackend) GetDevices() ([]Device, error) { return devices, nil } +// ControlledOutputs returns the output names (DP-2, ...) of monitors whose brightness is driven over DDC/CI. +func (b *DDCBackend) ControlledOutputs() []string { + connectors := []string{} + b.devices.Range(func(_ string, dev *ddcDevice) bool { + if dev.connector != "" { + connectors = append(connectors, dev.connector) + } + return true + }) + return connectors +} + func (b *DDCBackend) SetBrightness(id string, value int, exponential bool, callback func()) error { return b.SetBrightnessWithExponent(id, value, exponential, 1.2, callback) } diff --git a/core/internal/server/brightness/manager.go b/core/internal/server/brightness/manager.go index c67cb2568..b8b4af2e2 100644 --- a/core/internal/server/brightness/manager.go +++ b/core/internal/server/brightness/manager.go @@ -42,6 +42,11 @@ func (m *Manager) initLogind() { } func (m *Manager) initDDC() { + defer func() { + m.ddcSettled.Store(true) + m.updateState() + }() + ddc, err := newDDCBackend(m.updateState) switch { case errors.Is(err, errDDCDisabled): @@ -55,8 +60,6 @@ func (m *Manager) initDDC() { m.ddcBackend = ddc m.ddcReady = true log.Info("DDC backend initialized") - - m.updateState() } func (m *Manager) Rescan() { @@ -82,7 +85,8 @@ func sortDevices(devices []Device) { classOrder := map[DeviceClass]int{ ClassBacklight: 0, ClassDDC: 1, - ClassLED: 2, + ClassSoftware: 2, + ClassLED: 3, } orderI := classOrder[devices[i].Class] @@ -142,6 +146,7 @@ func (m *Manager) updateState() { } } + allDevices = append(allDevices, m.softwareDevices(allDevices)...) sortDevices(allDevices) m.stateMutex.Lock() @@ -211,6 +216,13 @@ func (m *Manager) SetBrightnessWithExponent(deviceID string, percent int, expone m.updateState() m.debouncedBroadcast(deviceID) }) + case deviceClass == ClassSoftware: + gamma := m.gamma.Load() + if gamma == nil { + err = fmt.Errorf("no gamma control for %s", deviceID) + break + } + err = gamma.SetOutputBrightness(strings.TrimPrefix(deviceID, softwareIDPrefix), percent) case m.logindReady && m.logindBackend != nil: log.Debugf("Calling logind backend for %s", deviceID) err = m.setViaSysfsWithLogindWithExponent(deviceID, percent, exponential, exponent) diff --git a/core/internal/server/brightness/software.go b/core/internal/server/brightness/software.go new file mode 100644 index 000000000..bd16b598b --- /dev/null +++ b/core/internal/server/brightness/software.go @@ -0,0 +1,124 @@ +package brightness + +import ( + "maps" + "regexp" + "slices" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" +) + +const softwareIDPrefix = "software:" + +var internalOutputPattern = regexp.MustCompile(`^(eDP|LVDS|DSI)-`) + +// uncoveredOutputs returns the outputs no backlight or DDC device controls. A +// sysfs backlight cannot be tied to a connector, so it is taken to cover every +// internal panel. +func uncoveredOutputs(outputs []string, hasBacklight bool, ddcConnectors []string) []string { + uncovered := []string{} + for _, name := range outputs { + switch { + case strings.HasPrefix(name, "HEADLESS-"): + continue + case hasBacklight && internalOutputPattern.MatchString(name): + continue + case slices.Contains(ddcConnectors, name): + continue + } + uncovered = append(uncovered, name) + } + return uncovered +} + +// takenOverOutputs returns the connected outputs that still have a software +// level but are now controlled by hardware, so their ramp would stay dimmed +// with no slider to undo it. +func takenOverOutputs(outputs, uncovered []string, levels map[string]int) []string { + takenOver := []string{} + for name := range levels { + if !slices.Contains(outputs, name) || slices.Contains(uncovered, name) { + continue + } + takenOver = append(takenOver, name) + } + return takenOver +} + +func (m *Manager) softwareDevices(hardware []Device) []Device { + gamma := m.gamma.Load() + if gamma == nil || !m.ddcSettled.Load() { + return nil + } + + hasBacklight := slices.ContainsFunc(hardware, func(d Device) bool { return d.Class == ClassBacklight }) + var ddcConnectors []string + if m.ddcReady && m.ddcBackend != nil { + ddcConnectors = m.ddcBackend.ControlledOutputs() + } + + outputs := gamma.ListOutputs() + uncovered := uncoveredOutputs(outputs, hasBacklight, ddcConnectors) + levels := gamma.GetOutputBrightness() + for _, name := range takenOverOutputs(outputs, uncovered, levels) { + if err := gamma.SetOutputBrightness(name, 100); err != nil { + log.Debugf("failed to drop software brightness for %s: %v", name, err) + } + } + + refused := gamma.GammaRefusedOutputs() + devices := []Device{} + for _, name := range uncovered { + if slices.Contains(refused, name) { + continue + } + percent, dimmed := levels[name] + if !dimmed { + percent = 100 + } + devices = append(devices, Device{ + Class: ClassSoftware, + ID: softwareIDPrefix + name, + Name: name, + Current: percent, + Max: 100, + CurrentPercent: percent, + Backend: "gamma", + }) + } + return devices +} + +// AttachGamma enables software brightness for outputs without a backlight or +// DDC/CI. Only output and brightness changes are followed, not night light steps. +func (m *Manager) AttachGamma(gamma *wayland.Manager) { + m.gamma.Store(gamma) + m.updateState() + + const subscriberID = "brightness-software" + ch := gamma.Subscribe(subscriberID) + go func() { + defer gamma.Unsubscribe(subscriberID) + var outputs, refused []string + var levels map[string]int + for { + select { + case <-m.stopChan: + return + case state, ok := <-ch: + if !ok { + return + } + if slices.Equal(outputs, state.Outputs) && maps.Equal(levels, state.Config.OutputBrightness) && slices.Equal(refused, state.GammaRefused) { + continue + } + outputs = state.Outputs + levels = state.Config.OutputBrightness + refused = state.GammaRefused + m.updateState() + } + } + }() +} diff --git a/core/internal/server/brightness/software_test.go b/core/internal/server/brightness/software_test.go new file mode 100644 index 000000000..c9f6c2aa1 --- /dev/null +++ b/core/internal/server/brightness/software_test.go @@ -0,0 +1,56 @@ +package brightness + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUncoveredOutputs(t *testing.T) { + tests := []struct { + name string + outputs []string + hasBacklight bool + ddc []string + want []string + }{ + {"internal panel with backlight", []string{"eDP-1"}, true, nil, []string{}}, + {"oled panel without backlight", []string{"eDP-1"}, false, nil, []string{"eDP-1"}}, + {"external monitor with ddc", []string{"DP-1"}, false, []string{"DP-1"}, []string{}}, + {"external monitor without ddc", []string{"DP-1"}, true, nil, []string{"DP-1"}}, + {"headless output", []string{"HEADLESS-1"}, false, nil, []string{}}, + { + "laptop with mixed monitors", + []string{"DP-1", "HDMI-A-1", "eDP-1"}, true, []string{"HDMI-A-1"}, + []string{"DP-1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, uncoveredOutputs(tt.outputs, tt.hasBacklight, tt.ddc)) + }) + } +} + +func TestTakenOverOutputs(t *testing.T) { + dimmed := map[string]int{"DP-1": 40} + tests := []struct { + name string + outputs []string + uncovered []string + levels map[string]int + want []string + }{ + {"ddc took over a dimmed output", []string{"DP-1"}, []string{}, dimmed, []string{"DP-1"}}, + {"dimmed output still uncovered", []string{"DP-1"}, []string{"DP-1"}, dimmed, []string{}}, + {"dimmed output disconnected", []string{"HDMI-A-1"}, []string{"HDMI-A-1"}, dimmed, []string{}}, + {"covered output without a level", []string{"DP-1"}, []string{}, nil, []string{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, takenOverOutputs(tt.outputs, tt.uncovered, tt.levels)) + }) + } +} diff --git a/core/internal/server/brightness/types.go b/core/internal/server/brightness/types.go index 768634d63..57bfd0a6f 100644 --- a/core/internal/server/brightness/types.go +++ b/core/internal/server/brightness/types.go @@ -2,8 +2,10 @@ package brightness import ( "sync" + "sync/atomic" "time" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/dankgo/syncmap" ) @@ -13,6 +15,7 @@ const ( ClassBacklight DeviceClass = "backlight" ClassLED DeviceClass = "leds" ClassDDC DeviceClass = "ddc" + ClassSoftware DeviceClass = "software" ) type Device struct { @@ -49,10 +52,13 @@ type Manager struct { nativeBackend Backend ddcBackend *DDCBackend monitor deviceMonitor + gamma atomic.Pointer[wayland.Manager] logindReady bool nativeReady bool ddcReady bool + // Until DDC has probed, every external monitor looks uncovered. + ddcSettled atomic.Bool exponential bool @@ -114,6 +120,7 @@ type ddcDevice struct { id string name string adapter string + connector string max int lastBrightness int } diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 03cb7326e..2d7cedf76 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -243,6 +243,9 @@ func InitializeBrightnessManager() error { } brightnessManager = manager + if waylandManager != nil { + manager.AttachGamma(waylandManager) + } log.Info("Brightness manager initialized") return nil diff --git a/core/internal/server/wayland/gamma.go b/core/internal/server/wayland/gamma.go index b7ef43631..c61ffaa6c 100644 --- a/core/internal/server/wayland/gamma.go +++ b/core/internal/server/wayland/gamma.go @@ -152,6 +152,19 @@ func GenerateGammaRamp(size uint32, temp int, gamma, contrast float64) GammaRamp return ramp } +func brightnessScale(percent int) float64 { + return outputBrightnessFloor + (1-outputBrightnessFloor)*float64(percent)/100 +} + +// scaleRamp dims the ramp in perceived brightness, not light output. +func scaleRamp(ramp GammaRamp, factor float64) { + for _, channel := range [][]uint16{ramp.Red, ramp.Green, ramp.Blue} { + for i, v := range channel { + channel[i] = uint16(float64(v) * factor) + } + } +} + func GenerateIdentityRamp(size uint32) GammaRamp { ramp := GammaRamp{ Red: make([]uint16, size), diff --git a/core/internal/server/wayland/gamma_test.go b/core/internal/server/wayland/gamma_test.go index ecfd02500..94a8ee628 100644 --- a/core/internal/server/wayland/gamma_test.go +++ b/core/internal/server/wayland/gamma_test.go @@ -243,3 +243,12 @@ func TestProfileRampWithTempAppliesGammaAndContrast(t *testing.T) { }) } } + +func TestBrightnessScale_NeverReachesBlack(t *testing.T) { + if got := brightnessScale(0); got != outputBrightnessFloor { + t.Errorf("brightnessScale(0) = %v, want the floor %v", got, outputBrightnessFloor) + } + if got := brightnessScale(100); got != 1 { + t.Errorf("brightnessScale(100) = %v, want 1", got) + } +} diff --git a/core/internal/server/wayland/iccconfig.go b/core/internal/server/wayland/iccconfig.go index 3f1dc133b..6e5a5e575 100644 --- a/core/internal/server/wayland/iccconfig.go +++ b/core/internal/server/wayland/iccconfig.go @@ -16,8 +16,9 @@ import ( ) type iccConfig struct { - ICCProfiles map[string]string `json:"iccProfiles,omitempty"` - OutputTemps map[string]int `json:"outputTemps,omitempty"` + ICCProfiles map[string]string `json:"iccProfiles,omitempty"` + OutputTemps map[string]int `json:"outputTemps,omitempty"` + OutputBrightness map[string]int `json:"outputBrightness,omitempty"` } func dmsConfigDir() string { @@ -55,15 +56,16 @@ func LoadConfig() Config { } cfg.ICCProfiles = stored.ICCProfiles cfg.OutputTemps = stored.OutputTemps + cfg.OutputBrightness = stored.OutputBrightness return cfg } -func SaveICCConfig(iccProfiles map[string]string, outputTemps map[string]int) error { +func SaveICCConfig(cfg Config) error { path := iccConfigPath() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - data, err := json.MarshalIndent(iccConfig{ICCProfiles: iccProfiles, OutputTemps: outputTemps}, "", " ") + data, err := json.MarshalIndent(iccConfig{ICCProfiles: cfg.ICCProfiles, OutputTemps: cfg.OutputTemps, OutputBrightness: cfg.OutputBrightness}, "", " ") if err != nil { return err } @@ -153,7 +155,7 @@ func migrateLegacyICCConfig() { cfg.ICCProfiles[output] = name } - if err := SaveICCConfig(cfg.ICCProfiles, cfg.OutputTemps); err != nil { + if err := SaveICCConfig(Config{ICCProfiles: cfg.ICCProfiles, OutputTemps: cfg.OutputTemps}); err != nil { log.Warnf("icc: cannot migrate %s: %v", legacyPath, err) return } diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index 46791c38b..1911f4632 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -133,6 +133,7 @@ func (m *Manager) post(fn func()) { func cloneConfig(cfg Config) Config { cfg.ICCProfiles = maps.Clone(cfg.ICCProfiles) cfg.OutputTemps = maps.Clone(cfg.OutputTemps) + cfg.OutputBrightness = maps.Clone(cfg.OutputBrightness) return cfg } @@ -338,6 +339,11 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co out.failed = false out.retryCount = 0 out.lastTemp = 0 + if out.gammaRefused { + out.gammaRefused = false + m.publishICCState() + m.updateStateFromSchedule() + } } m.applyCurrentTemp("gamma_size") }) @@ -353,10 +359,11 @@ func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_co ctrl.Destroy() } out.gammaControl = nil - out.failed = true - out.rampSize = 0 - out.retryCount++ - out.lastFailTime = time.Now() + out.markFailed(time.Now()) + if out.gammaRefused { + m.publishICCState() + m.updateStateFromSchedule() + } if !m.outputStillValid(out) { return @@ -1011,6 +1018,9 @@ func (m *Manager) applyGamma(temp int) { } ramp = GenerateGammaRamp(out.rampSize, outTemp, gamma, contrast) } + if out.dimScale > 0 { + scaleRamp(ramp, out.dimScale) + } buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6)) for _, v := range ramp.Red { binary.Write(buf, binary.LittleEndian, v) @@ -1123,6 +1133,7 @@ func (m *Manager) updateStateFromSchedule() { SunPosition: pos, ICCProfiles: m.GetICCStatus(), OutputTemps: m.GetOutputTemps(), + GammaRefused: m.GammaRefusedOutputs(), Outputs: m.ListOutputs(), } @@ -1392,7 +1403,8 @@ func (m *Manager) needsControls() bool { m.config.Gamma != 1.0 || m.config.Contrast != 1.0 || len(m.config.ICCProfiles) > 0 || - len(m.config.OutputTemps) > 0 + len(m.config.OutputTemps) > 0 || + len(m.config.OutputBrightness) > 0 } func (m *Manager) syncControls() { @@ -1472,7 +1484,7 @@ func (m *Manager) loadConfiguredICC() bool { iccProfiles := config.ICCProfiles outputTemps := config.OutputTemps - if len(iccProfiles) == 0 && len(outputTemps) == 0 { + if len(iccProfiles) == 0 && len(outputTemps) == 0 && len(config.OutputBrightness) == 0 { return false } @@ -1482,7 +1494,7 @@ func (m *Manager) loadConfiguredICC() bool { if !ok { return true } - if m.applyConfiguredICCForOutput(out, name, iccProfiles, outputTemps) { + if m.applyConfiguredICCForOutput(out, name, config) { attached++ } return true @@ -1502,7 +1514,7 @@ func (m *Manager) attachConfiguredICC(outputID uint32, outputName string) { iccProfiles := config.ICCProfiles outputTemps := config.OutputTemps - if len(iccProfiles) == 0 && len(outputTemps) == 0 { + if len(iccProfiles) == 0 && len(outputTemps) == 0 && len(config.OutputBrightness) == 0 { m.publishICCState() return } @@ -1512,7 +1524,7 @@ func (m *Manager) attachConfiguredICC(outputID uint32, outputName string) { if out.id != outputID { return true } - changed = m.applyConfiguredICCForOutput(out, outputName, iccProfiles, outputTemps) + changed = m.applyConfiguredICCForOutput(out, outputName, config) if changed { log.Infof("icc: attached configured profile/temperature to output %s", outputName) out.lastTemp = 0 @@ -1531,15 +1543,20 @@ func (m *Manager) attachConfiguredICC(outputID uint32, outputName string) { // 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 { +func (m *Manager) applyConfiguredICCForOutput(out *outputState, outputName string, config Config) bool { changed := false - if temp, ok := outputTemps[outputName]; ok { + if temp, ok := config.OutputTemps[outputName]; ok { out.outputTemp = temp changed = true } - if profileName, ok := iccProfiles[outputName]; ok { + if percent, ok := config.OutputBrightness[outputName]; ok { + out.dimScale = brightnessScale(percent) + changed = true + } + + if profileName, ok := config.ICCProfiles[outputName]; ok { iccPath := resolveICCProfile(profileName) profile, err := icc.ParseFile(iccPath) switch { @@ -1654,7 +1671,7 @@ func (m *Manager) ApplyICC(outputName, iccPath string) error { savedConfig := cloneConfig(m.config) m.configMutex.Unlock() - if err := SaveICCConfig(savedConfig.ICCProfiles, savedConfig.OutputTemps); err != nil { + if err := SaveICCConfig(savedConfig); err != nil { log.Warnf("icc: failed to save config: %v", err) } @@ -1672,7 +1689,7 @@ func (m *Manager) RemoveICC(outputName string) error { savedConfig := cloneConfig(m.config) m.configMutex.Unlock() - if err := SaveICCConfig(savedConfig.ICCProfiles, savedConfig.OutputTemps); err != nil { + if err := SaveICCConfig(savedConfig); err != nil { log.Warnf("icc: failed to save config: %v", err) } @@ -1723,6 +1740,7 @@ func (m *Manager) releaseIdleControls() bool { func (m *Manager) publishICCState() { status := make(map[string]*ICCStatus) temps := make(map[string]int) + refused := []string{} m.configMutex.RLock() for name, temp := range m.config.OutputTemps { @@ -1743,12 +1761,17 @@ func (m *Manager) publishICCState() { if out.iccPath != "" { status[name] = describeICCProfile(out.iccPath, out.iccProfile) } + if out.gammaRefused { + refused = append(refused, name) + } return true }) + slices.Sort(refused) m.iccStateMutex.Lock() m.iccStatus = status m.iccTemps = temps + m.iccRefused = refused m.iccStateMutex.Unlock() } @@ -1827,7 +1850,7 @@ func (m *Manager) SetOutputTemp(outputName string, temp int) error { savedConfig := cloneConfig(m.config) m.configMutex.Unlock() - if err := SaveICCConfig(savedConfig.ICCProfiles, savedConfig.OutputTemps); err != nil { + if err := SaveICCConfig(savedConfig); err != nil { log.Warnf("icc: failed to save config: %v", err) } @@ -1870,6 +1893,80 @@ func (m *Manager) SetOutputTemp(outputName string, temp int) error { return nil } +// SetOutputBrightness dims an output through its gamma ramp, for outputs +// without a backlight or DDC/CI. 100 removes the override. +func (m *Manager) SetOutputBrightness(outputName string, percent int) error { + if err := validateOutputName(outputName); err != nil { + return err + } + percent = min(max(percent, 0), 100) + + m.configMutex.Lock() + current, configured := m.config.OutputBrightness[outputName] + if (configured && current == percent) || (!configured && percent == 100) { + m.configMutex.Unlock() + return nil + } + if m.config.OutputBrightness == nil { + m.config.OutputBrightness = make(map[string]int) + } + if percent == 100 { + delete(m.config.OutputBrightness, outputName) + } else { + m.config.OutputBrightness[outputName] = percent + } + savedConfig := cloneConfig(m.config) + m.configMutex.Unlock() + + if err := SaveICCConfig(savedConfig); err != nil { + log.Warnf("gamma: failed to save config: %v", err) + } + + scale := brightnessScale(percent) + if percent == 100 { + scale = 0 + } + + m.post(func() { + defer m.updateStateFromSchedule() + + if m.releaseIdleControls() { + return + } + if !m.ensureOutputControls() { + return + } + + m.outputs.Range(func(_ uint32, out *outputState) bool { + if name, ok := m.outputNames.Load(out.id); !ok || name != outputName { + return true + } + out.dimScale = scale + out.lastTemp = 0 + m.applyCurrentTemp("output-brightness-change") + return false + }) + }) + + return nil +} + +// GetOutputBrightness returns the configured software brightness per output; +// outputs at full brightness are absent. +func (m *Manager) GetOutputBrightness() map[string]int { + m.configMutex.RLock() + defer m.configMutex.RUnlock() + return maps.Clone(m.config.OutputBrightness) +} + +// GammaRefusedOutputs returns the outputs whose gamma control the compositor +// refused, so software brightness cannot dim them. +func (m *Manager) GammaRefusedOutputs() []string { + m.iccStateMutex.RLock() + defer m.iccStateMutex.RUnlock() + return slices.Clone(m.iccRefused) +} + // GetOutputTemps returns the published per-output temperatures. func (m *Manager) GetOutputTemps() map[string]int { m.iccStateMutex.RLock() diff --git a/core/internal/server/wayland/manager_test.go b/core/internal/server/wayland/manager_test.go index 6a4ac6e0f..6653c964b 100644 --- a/core/internal/server/wayland/manager_test.go +++ b/core/internal/server/wayland/manager_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "path/filepath" "sync" @@ -910,3 +911,58 @@ func TestManager_RemovingAbsentProfileDoesNotForceResend(t *testing.T) { assert.Equal(t, 4000, out.lastTemp, "removing a profile that was never attached must not clear the dedupe state") } + +func TestManager_SetOutputBrightnessClampsAndPersists(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() + }() + + tests := []struct { + percent int + want map[string]int + }{ + {0, map[string]int{"eDP-1": 0}}, + {40, map[string]int{"eDP-1": 40}}, + {150, nil}, + } + for _, tt := range tests { + assert.NoError(t, m.SetOutputBrightness("eDP-1", tt.percent)) + assert.True(t, maps.Equal(tt.want, m.GetOutputBrightness()), "percent %d", tt.percent) + assert.True(t, maps.Equal(tt.want, LoadConfig().OutputBrightness), "percent %d persisted", tt.percent) + } + assert.False(t, m.needsControls(), "full brightness must release the gamma controls") +} + +// A control that fails before gamma_size was refused by the compositor, so +// software brightness cannot use the output; a failure after it worked is a +// lost control that the retry path recovers. +func TestOutputState_MarkFailedDetectsRefusal(t *testing.T) { + tests := []struct { + name string + rampSize uint32 + refused bool + }{ + {"failed before gamma_size", 0, true}, + {"failed after gamma_size", 256, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := &outputState{rampSize: tt.rampSize, retryCount: 1} + out.markFailed(time.Now()) + assert.Equal(t, tt.refused, out.gammaRefused) + assert.True(t, out.failed) + assert.Zero(t, out.rampSize) + assert.Equal(t, 2, out.retryCount) + }) + } +} diff --git a/core/internal/server/wayland/types.go b/core/internal/server/wayland/types.go index 3119fa03f..193940222 100644 --- a/core/internal/server/wayland/types.go +++ b/core/internal/server/wayland/types.go @@ -1,6 +1,7 @@ package wayland import ( + "maps" "math" "slices" "sync" @@ -15,6 +16,10 @@ import ( "github.com/godbus/dbus/v5" ) +// outputBrightnessFloor is the ramp scale at 0%, so a software-dimmed output +// stays readable instead of going black. +const outputBrightnessFloor = 0.1 + type GammaState int const ( @@ -38,8 +43,9 @@ type Config struct { Enabled bool ElevationTwilight float64 ElevationDaylight float64 - ICCProfiles map[string]string `json:"iccProfiles,omitempty"` // outputName -> file in ICCProfilesDir, or an absolute path - OutputTemps map[string]int `json:"outputTemps,omitempty"` // outputName -> per-output color temperature (K) + ICCProfiles map[string]string `json:"iccProfiles,omitempty"` // outputName -> file in ICCProfilesDir, or an absolute path + OutputTemps map[string]int `json:"outputTemps,omitempty"` // outputName -> per-output color temperature (K) + OutputBrightness map[string]int `json:"outputBrightness,omitempty"` // outputName -> software brightness percent, below 100 only } type State struct { @@ -55,6 +61,7 @@ type State struct { 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 + GammaRefused []string `json:"gammaRefused,omitempty"` } // ICCStatus represents the ICC profile status for a single output. @@ -119,6 +126,7 @@ type Manager struct { iccStateMutex sync.RWMutex iccStatus map[string]*ICCStatus // outputName -> status iccTemps map[string]int // outputName -> per-output temperature (K) + iccRefused []string // outputs whose gamma control the compositor refused, sorted cmdq chan cmd alive bool @@ -162,6 +170,19 @@ type outputState struct { 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 + dimScale float64 // software brightness ramp scale, 0 = full + gammaRefused bool // the latest control failed before reporting gamma_size +} + +// markFailed records a failed control. A failure before gamma_size is the +// compositor refusing the output (another client holds it, or it is not +// allowed there), as opposed to a control lost after it worked. +func (out *outputState) markFailed(now time.Time) { + out.gammaRefused = out.rampSize == 0 + out.failed = true + out.rampSize = 0 + out.retryCount++ + out.lastFailTime = now } func DefaultConfig() Config { @@ -278,7 +299,10 @@ 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) { + if !maps.Equal(old.Config.OutputBrightness, new.Config.OutputBrightness) { + return true + } + if !slices.Equal(old.Outputs, new.Outputs) || !slices.Equal(old.GammaRefused, new.GammaRefused) { return true } for name, temp := range new.OutputTemps { diff --git a/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml b/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml index dc400367a..18e441aff 100644 --- a/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/BrightnessDetail.qml @@ -103,12 +103,12 @@ Item { function deviceIcon(device, brightness) { const deviceClass = device.class || ""; - if (deviceClass === "backlight" || deviceClass === "ddc") { - if (brightness <= CcMetrics.brightnessLowMax) - return "brightness_low"; - return brightness <= CcMetrics.brightnessMediumMax ? "brightness_medium" : "brightness_high"; - } - return (device.name || "").includes("kbd") ? "keyboard" : "lightbulb"; + if (deviceClass !== "backlight" && deviceClass !== "ddc" && deviceClass !== "software") + return (device.name || "").includes("kbd") ? "keyboard" : "lightbulb"; + if (brightness <= CcMetrics.brightnessLowMax) + return "brightness_low"; + + return brightness <= CcMetrics.brightnessMediumMax ? "brightness_medium" : "brightness_high"; } function deviceClassLabel(device) { @@ -119,6 +119,8 @@ Item { return I18n.tr("DDC/CI monitor"); case "leds": return I18n.tr("LED device"); + case "software": + return I18n.tr("Software dimming", "brightness device class label, dimmed through the gamma ramp"); default: return device.class || ""; } @@ -192,14 +194,14 @@ Item { } CcToggleRow { - visible: deviceItem.active + visible: deviceItem.active && deviceItem.modelData.class !== "software" text: I18n.tr("Exponential", "adjective, toggle for an exponential brightness curve per device") checked: deviceItem.exponential onToggled: checked => SessionData.setBrightnessExponential(deviceItem.deviceName, checked) } CcListRow { - visible: deviceItem.active && deviceItem.exponential + visible: deviceItem.active && deviceItem.exponential && deviceItem.modelData.class !== "software" title: I18n.tr("Curve") DNumberStepper { diff --git a/quickshell/Modules/OSD/BrightnessOSD.qml b/quickshell/Modules/OSD/BrightnessOSD.qml index 76244f2b0..375b395c3 100644 --- a/quickshell/Modules/OSD/BrightnessOSD.qml +++ b/quickshell/Modules/OSD/BrightnessOSD.qml @@ -17,6 +17,7 @@ LevelOSD { switch (device?.class) { case "backlight": case "ddc": + case "software": return "monitor"; default: return BrightnessService.brightnessIconName(device); diff --git a/quickshell/Services/BrightnessService.qml b/quickshell/Services/BrightnessService.qml index e32892368..b7f5cd999 100644 --- a/quickshell/Services/BrightnessService.qml +++ b/quickshell/Services/BrightnessService.qml @@ -30,7 +30,6 @@ Singleton { return getDeviceBrightness(deviceToUse); } - property int maxBrightness: 100 property bool brightnessInitialized: false property bool suppressOsd: true @@ -209,9 +208,8 @@ Singleton { if (deviceExists) { setCurrentDevice(lastDevice, false); } else { - const backlight = internalPanelActive() ? devices.find(d => d.class === "backlight") : null; const nonKbdDevice = devices.find(d => !d.id.includes("kbd")); - const defaultDevice = backlight || nonKbdDevice || devices[0]; + const defaultDevice = internalPanelDevice() || nonKbdDevice || devices[0]; setCurrentDevice(defaultDevice.id, false); } } @@ -239,7 +237,7 @@ Singleton { } const deviceInfo = getCurrentDeviceInfoByName(actualDevice); - const isExponential = SessionData.getBrightnessExponential(actualDevice); + const isExponential = deviceInfo?.class !== "software" && SessionData.getBrightnessExponential(actualDevice); const minValue = brightnessMinimum(deviceInfo); const maxValue = brightnessMaximum(deviceInfo); @@ -333,17 +331,23 @@ Singleton { } function internalPanelActive() { - return Quickshell.screens.some(s => /^(eDP|LVDS|DSI)/i.test(s.name)); + return Quickshell.screens.some(s => isInternalPanelName(s.name)); + } + + function isInternalPanelName(name) { + return /^(eDP|LVDS|DSI)/i.test(name); + } + + function internalPanelDevice() { + if (!internalPanelActive()) + return null; + return devices.find(d => d.class === "backlight") || devices.find(d => d.class === "software" && isInternalPanelName(d.id.split(":").pop())) || null; } function getDefaultDevice() { - if (internalPanelActive()) { - for (const device of devices) { - if (device.class === "backlight") { - return device.id; - } - } - } + const internal = internalPanelDevice(); + if (internal) + return internal.id; const nonKbdDevice = devices.find(d => d.class !== "backlight" && !d.id.includes("kbd")); if (nonKbdDevice) return nonKbdDevice.id; @@ -416,7 +420,7 @@ Singleton { } function brightnessMinimum(deviceInfo) { - if (!deviceInfo || SessionData.getBrightnessExponential(deviceInfo.id)) + if (!deviceInfo || (deviceInfo.class !== "software" && SessionData.getBrightnessExponential(deviceInfo.id))) return 1; switch (deviceInfo.class) { case "backlight": @@ -452,6 +456,7 @@ Singleton { switch (deviceInfo.class) { case "backlight": case "ddc": + case "software": break; default: return String(deviceInfo.name ?? "").includes("kbd") ? "keyboard" : "lightbulb";