Watch
1
0
Fork
You've already forked souveraine
0

gamma: the brightness slider drives viewtop's LUT, not hyprctl (TASK-61)

This commit is contained in:
Fimeg 2026-08-06 18:57:01 -04:00
commit 4ff8d2e236
3 changed files with 250 additions and 0 deletions

View file

@ -59,6 +59,7 @@ modules/souveraine/windowSheet/PowerMenu.qml souveraine/modules/souveraine/windo
modules/souveraine/windowSheet/qmldir souveraine/modules/souveraine/windowSheet/qmldir
services/Face.qml souveraine/services/Face.qml
services/ViewtopControl.qml souveraine/services/ViewtopControl.qml
services/Hyprsunset.qml souveraine/services/Hyprsunset.qml
modules/souveraine/subconscious/SubconsciousTicker.qml souveraine/modules/souveraine/subconscious/SubconsciousTicker.qml
modules/souveraine/subconscious/SubconsciousEventPanel.qml souveraine/modules/souveraine/subconscious/SubconsciousEventPanel.qml
modules/souveraine/subconscious/qmldir souveraine/modules/souveraine/subconscious/qmldir

View file

@ -0,0 +1,202 @@
// The colour ramp: dimming below the backlight's floor, and the evening warmth.
//
// **The name is upstream's and it is now a lie.** There is no hyprsunset here,
// and no `hyprctl` to reach one. It keeps the name because every consumer in the
// tree `QuickSliders`, `BarContent`, `NightLightToggle`, `BrightnessIndicator`,
// `OnScreenDisplay` spells it, and renaming a singleton across the base tree
// and the phone overlay is a separate sweep from making the control work. What
// this file is: the same public surface (`gamma`, `gammaLowerLimit`, `setGamma`,
// `temperatureActive`, `toggleTemperature`, `fetchState`, `load`, and the
// `gammaChangeAttempt` signal) pointed at the authority that actually owns the
// LUT.
//
// What it replaces: `hyprctl hyprsunset gamma|temperature`, plus a `pidof
// hyprsunset || hyprsunset` that spawned a daemon on every call. Under viewtop
// that binary does not exist, so **the slider and the night-light have done
// nothing at all since the viewtop move** silently, because `execDetached`
// cannot fail loudly. Gamma is a pixel claim on the glass, so it goes to the
// compositor, which serves it as a verb and applies it to the CRTC's own LUT.
// TASK-61 Part 1/5.
//
// One writer, one ramp. Brightness scaling and the evening warmth are *not* two
// controls here they are two curve generators composed into a single LUT on
// the far side, which is why every path below funnels through `_apply()` and
// sends both numbers at once. Two callers each owning half of one hardware slot
// is trap #5 in START-HERE, and it has cost this project three sessions.
pragma Singleton
import QtQuick
import qs.modules.common
Singleton {
id: root
signal gammaChangeAttempt()
// The panel's backlight has a floor, and below it the only way further down
// is the ramp. That is what the bottom 30% of the brightness slider drives,
// so gamma is a *dimming* control here before it is a colour one.
readonly property real gammaLowerLimit: 25
property string from: Config.options?.light?.night?.from ?? "19:00"
property string to: Config.options?.light?.night?.to ?? "06:30"
property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true)
property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000
// Upstream sent 6000 K to mean "off", because hyprsunset had no way to say
// *balanced*. The verb does: omitting the temperature leaves the channels
// untouched. Kept because the config surface still names it, but nothing
// below sends it as a value any more off is absence, not a warm-ish number.
property int defaultColorTemperature: 6000
property int gamma: 100
property bool shouldBeOn
property bool firstEvaluation: true
property bool temperatureActive: false
property int fromHour: Number(from.split(":")[0])
property int fromMinute: Number(from.split(":")[1])
property int toHour: Number(to.split(":")[0])
property int toMinute: Number(to.split(":")[1])
property int clockHour: DateTime.clock.hours
property int clockMinute: DateTime.clock.minutes
property var manualActive
property int manualActiveHour
property int manualActiveMinute
onClockMinuteChanged: reEvaluate()
onAutomaticChanged: {
root.manualActive = undefined;
root.firstEvaluation = true;
reEvaluate();
}
function inBetween(t, from, to) {
if (from < to) {
return (t >= from && t <= to);
} else {
// Wrapped around midnight
return (t >= from || t <= to);
}
}
function reEvaluate() {
const t = clockHour * 60 + clockMinute;
const from = fromHour * 60 + fromMinute;
const to = toHour * 60 + toMinute;
const manualActive = manualActiveHour * 60 + manualActiveMinute;
if (root.manualActive !== undefined && (inBetween(from, manualActive, t) || inBetween(to, manualActive, t))) {
root.manualActive = undefined;
}
root.shouldBeOn = inBetween(t, from, to);
if (firstEvaluation) {
firstEvaluation = false;
root.ensureState();
}
}
onShouldBeOnChanged: ensureState()
function ensureState() {
if (!root.automatic || root.manualActive !== undefined)
return;
if (root.shouldBeOn) {
root.enableTemperature();
} else {
root.disableTemperature();
}
}
// Push the ramp we believe in to the compositor.
//
// The single write path. Both halves travel together because the far side
// composes them into one curve sending them separately would mean the
// second call overwriting the first's contribution.
function _apply() {
ViewtopControl.gamma(root.gamma, root.temperatureActive ? root.colorTemperature : 0);
}
function load() {
// No daemon to start any more; the compositor is already running, and if
// it is not there is nothing a shell could do about it. Ask what ramp is
// in force before deciding anything, then let the calendar rule.
ViewtopControl.refreshState();
root.ensureState();
}
// Take the compositor's answer as the truth about the ramp.
//
// Rule 2 of the house style: never hold state the protocol owns. The
// compositor is the LUT's only writer and its ramp starts at identity, so a
// compositor restart an ordinary event, and how every viewtop upgrade
// lands leaves this singleton believing in a warmth the panel no longer
// has. It would then refuse to re-warm, because it already thought it had.
//
// Adopting rather than re-asserting is also what keeps this to one writer:
// if the adopted state is not what the calendar wants, `ensureState()` sets
// it right on the next minute tick. The two ends converge instead of
// fighting over the slot.
Connections {
target: ViewtopControl
function onGammaValueChanged() { root._adopt(); }
function onGammaTemperatureChanged() { root._adopt(); }
}
function _adopt() {
// -1 is "never answered". Adopting it would drive the slider to a value
// no panel ever had.
if (ViewtopControl.gammaValue < 0)
return;
root.gamma = ViewtopControl.gammaValue;
root.temperatureActive = ViewtopControl.gammaTemperature > 0;
}
function enableTemperature() {
root.temperatureActive = true;
root._apply();
}
function disableTemperature() {
root.temperatureActive = false;
root._apply();
}
function setGamma(gamma) {
root.gamma = Math.max(root.gammaLowerLimit, Math.min(100, gamma));
root.gammaChangeAttempt();
root._apply();
}
// Ask the compositor what is in force. Answers asynchronously, into
// `_adopt()` above the toggle that calls this wants the panel's truth, not
// this file's memory of it.
function fetchState() {
ViewtopControl.refreshState();
}
function toggleTemperature(active = undefined) {
if (root.manualActive === undefined) {
root.manualActive = root.temperatureActive;
root.manualActiveHour = root.clockHour;
root.manualActiveMinute = root.clockMinute;
}
root.manualActive = active !== undefined ? active : !root.manualActive;
if (root.manualActive) {
root.enableTemperature();
} else {
root.disableTemperature();
}
}
// Change temp while the evening is already on. Re-sends the whole ramp
// rather than a temperature alone, for the reason `_apply()` gives.
Connections {
target: Config.options.light.night
function onColorTemperatureChanged() {
if (!root.temperatureActive) return;
root._apply();
}
}
}

View file

@ -72,6 +72,24 @@ Singleton {
root._send({ op: "workspaces" });
}
// The colour ramp in force, as the compositor last reported it. `-1` is
// "not asked yet" and is deliberately not 100: 100 is identity, a real
// answer, so defaulting to it would have every surface believe the panel
// was untinted before the first reply.
//
// Read back rather than remembered because the compositor is the writer of
// record. A compositor restart resets its ramp to identity, and a shell
// that cached its own idea of the ramp across that would draw a night-light
// indicator over a panel that is no longer warm.
property int gammaValue: -1
property int gammaTemperature: 0
// Panel power, outputs, borders and the ramp. Answers into `gammaValue` /
// `gammaTemperature`.
function refreshState() {
root._send({ op: "state" });
}
// True once the compositor has accepted a `subscribe` and is pushing canvas
// changes down the second socket below.
property bool subscribed: false
@ -124,6 +142,13 @@ Singleton {
root.activeZone = reply.active;
if (reply.count !== undefined)
root.zoneCount = reply.count;
// A `state` reply carries the ramp. `temperature` is null when the
// channels are balanced, which is not the same as 0 K flatten it to 0
// here so a consumer can test one number.
if (reply.gamma !== undefined) {
root.gammaValue = reply.gamma.value;
root.gammaTemperature = reply.gamma.temperature || 0;
}
}
// The push channel: a second, long-lived connection that carries canvas
@ -376,6 +401,28 @@ Singleton {
root.scene("focus", { id: id });
}
// Set the panel's colour ramp. `value` is 0..=100 and scales the curve;
// 100 with no temperature is identity. `temperature` is Kelvin.
//
// Gamma is a pixel claim on the glass, so the compositor owns it not
// sessiond, which owns device *states*. The slider used to reach `hyprctl
// hyprsunset`, which stopped existing with Hyprland and took both the
// dimming and the night-light with it, silently, since the viewtop move.
//
// One writer, one ramp: brightness scaling and the evening warmth are two
// curve generators composed into a single LUT on the far side rather than
// two callers racing for the same hardware slot. That is why this takes
// both arguments at once instead of offering a `temperature()` of its own.
//
// Refused with `unavailable` when the panel is off there is nothing to
// ramp so a caller must not read a refusal here as the verb missing.
function gamma(value, temperature) {
const msg = { value: Math.round(Math.max(0, Math.min(100, value))) };
if (temperature)
msg.temperature = Math.round(temperature);
root.scene("gamma", msg);
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock"