brightness: dim outputs without a backlight or ddc through gamma (#3813)

This commit is contained in:
cmleon51 authored and GitHub committed 2026-10-18 23:02:21 -04:00
1 parent e6f215b91d
commit 880135da67
15 files changed
+471 -47

No files matched your search

+13
View File
@@ -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)
}
+15 -3
View File
@@ -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)
+124
View File
@@ -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))
})
}
}
+7
View File
@@ -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
}
+3
View File
@@ -243,6 +243,9 @@ func InitializeBrightnessManager() error {
}
brightnessManager = manager
if waylandManager != nil {
manager.AttachGamma(waylandManager)
}
log.Info("Brightness manager initialized")
return nil
+13
View File
@@ -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)
}
}
+7 -5
View File
@@ -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
}
+112 -15
View File
@@ -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)
})
}
}
+27 -3
View File
@@ -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 {
+1
View File
@@ -17,6 +17,7 @@ LevelOSD {
switch (device?.class) {
case "backlight":
case "ddc":
case "software":
return "monitor";
default:
return BrightnessService.brightnessIconName(device);
+18 -13
View File
@@ -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";