mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-28 14:07:47 -04:00
brightness: dim outputs without a backlight or ddc through gamma (#3813)
This commit is contained in:
1 parent
e6f215b91d
commit
880135da67
15 files changed
+471
-47
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -243,6 +243,9 @@ func InitializeBrightnessManager() error {
|
||||
}
|
||||
|
||||
brightnessManager = manager
|
||||
if waylandManager != nil {
|
||||
manager.AttachGamma(waylandManager)
|
||||
}
|
||||
|
||||
log.Info("Brightness manager initialized")
|
||||
return nil
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,7 @@ LevelOSD {
|
||||
switch (device?.class) {
|
||||
case "backlight":
|
||||
case "ddc":
|
||||
case "software":
|
||||
return "monitor";
|
||||
default:
|
||||
return BrightnessService.brightnessIconName(device);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user