diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 46ae61f..315a33d 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -351,17 +351,10 @@ in ''; }; - # Night light — hyprsunset daemon, warms screen on a schedule. - # Adjust live: hyprctl hyprsunset temperature - services.hyprsunset = { - enable = true; - settings = { - profile = [ - { time = "7:00"; identity = true; } - { time = "21:00"; temperature = 4000; } - ]; - }; - }; + # Night light — hyprsunset applies the gamma; the schedule and + # temperature are driven by quickshell's quick-settings menu + # (settings/quickshell.nix) via `hyprctl hyprsunset`. + services.hyprsunset.enable = true; # Lock screen is quickshell's WlSessionLock (settings/quickshell.nix), # triggered via `qs ipc call lock lock`. diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 63be9e6..f6cf575 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -128,7 +128,7 @@ in *,*) lat=''${loc%,*}; lon=''${loc#*,} ;; *) lat=51.51; lon=-0.13 ;; esac - ${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7" + ${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset&timezone=auto&forecast_days=7" ''; in { "quickshell/qmldir" = { @@ -155,6 +155,7 @@ in readonly property color base04: "#${c.base04}" readonly property color base05: "#${c.base05}" readonly property color base08: "#${c.base08}" + readonly property color base09: "#${c.base09}" readonly property color base0A: "#${c.base0A}" readonly property color base0B: "#${c.base0B}" readonly property color base0C: "#${c.base0C}" @@ -446,6 +447,57 @@ in function unpin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(false); } } + // ── Night light: quickshell owns the schedule, the hyprsunset + // daemon just applies the gamma (hyprctl hyprsunset …). + // sunrise/sunset default to 7:00/21:00 and are replaced with + // real times by the bar's open-meteo weather fetch. + property int sunriseMins: 7 * 60 + property int sunsetMins: 21 * 60 + readonly property var nightlight: nlState + + FileView { + id: nlFile + path: Quickshell.env("HOME") + "/.local/state/quickshell-nightlight.json" + printErrors: false + adapter: JsonAdapter { + id: nlState + property bool autoOn: true + property real warmth: 0.5 // 0 = 6500K … 1 = 2500K + } + onLoaded: root.applyNightlight() + onLoadFailed: writeAdapter() // first run: create with defaults + } + + function setNightlight(autoOn, warmth) { + nlState.autoOn = autoOn; + nlState.warmth = warmth; + applyNightlight(); + nlFile.writeAdapter(); + } + + function applyNightlight() { + let w = nlState.warmth; + if (nlState.autoOn) { + let now = new Date(); + let mins = now.getHours() * 60 + now.getMinutes(); + if (mins >= sunriseMins && mins < sunsetMins) w = 0; // daytime + } + nlApply.command = w < 0.02 + ? ["hyprctl", "hyprsunset", "identity"] + : ["hyprctl", "hyprsunset", "temperature", String(Math.round(6500 - w * 4000))]; + nlApply.running = true; + } + + Process { id: nlApply } + + Timer { + // Re-assert once a minute: crosses the sunrise/sunset + // boundaries and heals a hyprsunset daemon that (re)started + // after us. + interval: 60000; running: true; repeat: true; triggeredOnStart: true + onTriggered: root.applyNightlight() + } + NotificationServer { id: _notifServer bodySupported: true @@ -678,6 +730,85 @@ in } } + // ── ToggleSwitch: small on/off pill. + component ToggleSwitch: Rectangle { + property bool on: false + signal toggled(bool v) + width: 34; height: 18; radius: 9 + color: on ? Theme.base0D : Theme.base02 + Behavior on color { ColorAnimation { duration: Theme.animFade } } + Rectangle { + width: 12; height: 12; radius: 6 + anchors.verticalCenter: parent.verticalCenter + x: parent.on ? parent.width - width - 3 : 3 + color: Theme.base05 + Behavior on x { NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutExpo } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: parent.toggled(!parent.on) + } + } + + // ── NightlightCard: colour-temperature control backed by + // shell.qml's night-light state. Auto on: follows + // sunrise/sunset and the slider sets the *warmest* colour the + // night reaches. Auto off: the slider is the temperature, now. + component NightlightCard: Card { + id: _nlc + cardSpacing: 6 + property var nl: bar.shellRoot.nightlight + + Item { + width: parent.width + height: 20 + Row { + spacing: 6 + anchors.verticalCenter: parent.verticalCenter + SIcon { anchors.verticalCenter: parent.verticalCenter; text: "nightlight"; font.pixelSize: 15 } + SText { anchors.verticalCenter: parent.verticalCenter; text: "Night Light"; font.weight: Font.Medium } + } + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + SText { anchors.verticalCenter: parent.verticalCenter; text: "Auto"; color: Theme.base04; font.pixelSize: 11 } + ToggleSwitch { + anchors.verticalCenter: parent.verticalCenter + on: _nlc.nl.autoOn + onToggled: (v) => bar.shellRoot.setNightlight(v, _nlc.nl.warmth) + } + } + } + + SText { + text: _nlc.nl.autoOn ? "Warmest colour (at night)" : "Colour temperature" + color: Theme.base04 + font.pixelSize: 10 + } + + Row { + width: parent.width + spacing: 8 + PillSlider { + width: parent.width - nlTempLabel.width - 8 + anchors.verticalCenter: parent.verticalCenter + value: _nlc.nl.warmth + fillColor: Theme.base09 + onMoved: (v) => bar.shellRoot.setNightlight(_nlc.nl.autoOn, v) + } + SText { + id: nlTempLabel + width: 38 + text: Math.round(6500 - _nlc.nl.warmth * 4000) + "K" + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + } + // ── NotifContent: summary + body + action chips for one // notification, shared by the calendar list and the toast. // Callers supply the container, dismiss button and sizes. @@ -1609,6 +1740,7 @@ in bar.toggleDropdown(batteryDropdown, function() { let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0); batteryDropdown.dropdownX = pos.x; + brightRead.running = true; // refresh sliders on open }); } @@ -1625,6 +1757,41 @@ in } ''} + ${lib.optionalString (!isMacbook) '' + // Quick settings (night light) — no battery widget on the + // desktop, so the card gets its own bar icon. + Item { + id: qsWidget + width: qsIcon.width + height: Theme.barHeight + + SIcon { + id: qsIcon + anchors.verticalCenter: parent.verticalCenter + text: "nightlight" + font.pixelSize: 16 + } + + function openQsDropdown() { + bar.toggleDropdown(qsDropdown, function() { + let pos = qsWidget.mapToItem(bar.contentItem, qsWidget.width / 2, 0); + qsDropdown.dropdownX = pos.x; + }); + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: qsWidget.openQsDropdown() + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== qsDropdown) qsWidget.openQsDropdown(); + } + } + } + } + ''} + // Tray icons Row { id: trayArea @@ -2386,6 +2553,86 @@ in } } } + + // Brightness card — screen + keyboard backlight. + // Values are read on dropdown open (brightRead); writes + // are debounced latest-wins so a drag doesn't spawn a + // brightnessctl per pixel. + Card { + id: brightCard + width: parent.width + cardSpacing: 6 + property real screenB: 0.5 + property real kbdB: 0 + + SText { text: "Brightness"; color: Theme.base04; font.pixelSize: 11 } + + Row { + width: parent.width + spacing: 8 + SIcon { width: 18; anchors.verticalCenter: parent.verticalCenter; text: "brightness_6"; font.pixelSize: 16 } + PillSlider { + width: parent.width - 26 + anchors.verticalCenter: parent.verticalCenter + value: brightCard.screenB + onMoved: (v) => { brightCard.screenB = v; screenBTimer.restart(); } + } + } + Row { + width: parent.width + spacing: 8 + SIcon { width: 18; anchors.verticalCenter: parent.verticalCenter; text: "keyboard"; font.pixelSize: 16 } + PillSlider { + width: parent.width - 26 + anchors.verticalCenter: parent.verticalCenter + value: brightCard.kbdB + onMoved: (v) => { brightCard.kbdB = v; kbdBTimer.restart(); } + } + } + + Timer { id: screenBTimer; interval: 80; onTriggered: screenBSet.running = true } + // floor 1%: `set 0%` turns the panel fully off + Process { id: screenBSet; command: ["brightnessctl", "set", Math.max(1, Math.round(brightCard.screenB * 100)) + "%"] } + Timer { id: kbdBTimer; interval: 80; onTriggered: kbdBSet.running = true } + Process { id: kbdBSet; command: ["brightnessctl", "-d", "smc::kbd_backlight", "set", Math.round(brightCard.kbdB * 100) + "%"] } + + Process { + id: brightRead + command: ["sh", "-c", "brightnessctl -m; brightnessctl -m -d smc::kbd_backlight"] + stdout: StdioCollector { + // machine-readable: device,class,current,percent%,max + onStreamFinished: { + for (let l of text.trim().split("\n")) { + let f = l.split(","); + if (f.length < 5) continue; + let pct = parseInt(f[3]) / 100; + if (f[0].indexOf("kbd") !== -1) brightCard.kbdB = pct; + else brightCard.screenB = pct; + } + } + } + } + } + + NightlightCard { width: parent.width } + } + } + ''} + + ${lib.optionalString (!isMacbook) '' + // Quick settings dropdown (desktop) — just the night light. + BarDropdown { + id: qsDropdown + alignRight: true + fullWidth: qsCol.width + 28 + fullHeight: qsCol.height + 20 + + Column { + id: qsCol + anchors.centerIn: parent + width: 240 + spacing: 8 + NightlightCard { width: parent.width } } } ''} @@ -2438,6 +2685,15 @@ in }); } if (out.length > 0) calPopup.weatherDays = out; + // Feed today's real sunrise/sunset to the + // night-light schedule (shell.qml). + if (j.daily.sunrise && j.daily.sunset) { + let sr = new Date(j.daily.sunrise[0]); + let ss = new Date(j.daily.sunset[0]); + bar.shellRoot.sunriseMins = sr.getHours() * 60 + sr.getMinutes(); + bar.shellRoot.sunsetMins = ss.getHours() * 60 + ss.getMinutes(); + bar.shellRoot.applyNightlight(); + } } catch (e) { /* keep previous forecast */ } } }