Skip to content

feat: dim screen on idle before screen-off#25

Open
jibsta210 wants to merge 4 commits into
pop-os:masterfrom
jibsta210:feat/lilypad-patches
Open

feat: dim screen on idle before screen-off#25
jibsta210 wants to merge 4 commits into
pop-os:masterfrom
jibsta210:feat/lilypad-patches

Conversation

@jibsta210
Copy link
Copy Markdown

Summary

Adds a "dim" idle stage that runs before the existing screen-off stage. After a configurable inactivity period, the backlight fades smoothly down to a low brightness (default 7%) over ~300ms. Any input restores it. When the next idle stage powers the screen off, we snap-restore the backlight to its pre-dim value after the screen has been switched to Off, so when the user later unlocks the hardware is already at its original brightness rather than stuck at 7%.

Motivation

Currently cosmic-idle has a binary screen-on / screen-off transition. A pre-dim stage gives the user a visible "you're about to sleep" cue and saves a small amount of energy on long idles. It also matches the typical behaviour of macOS / Windows / GNOME idle handling.

The "snap-restore after screen-off" trick is the subtle bit: if you don't restore the brightness while the screen is dark, the user comes back from lock to a backlight stuck at 7% until they wiggle the mouse hard enough to wake the dim restore — which is jarring. Doing the restore while the panel is off makes it invisible.

Implementation

  • src/dim.rs (new): DimState struct that:
    • captures the pre-dim brightness via logind's Session.Brightness property,
    • drives a smooth fade via repeated Session.SetBrightness calls on a calloop timer,
    • exposes start_dim, restore (smooth fade back), and snap_restore (instant, used after screen-off).
  • src/main.rs:
    • dim_idle_notification and dim_state fields on the main app state.
    • update_dim_idle handler that subscribes to the new dim-stage IdleNotification and starts the fade.
    • Restore on input via the existing activity handlers.
    • snap_restore() call inside fade_done() after set_mode(Off) so the user never sees the snap.
  • cosmic-idle-config/src/lib.rs: three new keys — dim_idle_timeout (seconds), dim_target_percent (u32, default 7), dim_fade_ms (u32, default 300).

Why Session.SetBrightness rather than the daemon's DisplayBrightness property

SetBrightness writes the kernel sysfs value directly without firing the property-change signal that the OSD listens to, so the brightness slider doesn't pop up on every fade frame. (This pairs with pop-os/cosmic-osd#194 / pop-os/cosmic-settings-daemon#144 for the hotkey-only OSD path; without those, this PR alone is still well-behaved with the OSD because logind doesn't trigger it.)

Testing

Daily-driven for several weeks. Verified that:

  • Wiggling the mouse during the fade aborts and restores smoothly.
  • After the screen powers off and is unlocked later, the backlight is at the original pre-dim value.
  • Lock-then-suspend-then-resume preserves the original brightness.
  • Disabling the new config keys (timeout = 0) is effectively a no-op.

AI Disclosure

Patches were drafted with assistance from Claude (Anthropic). Commits include Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> trailers. I understand each change and will respond to review feedback. I'm aware of the AI-PR policy in the template — happy to rework or close if maintainers prefer.

  • I have disclosed use of any AI generated code in my commit messages.
  • I understand these changes in full and will be able to respond to review comments.
  • My change is accurately described in the commit message.
  • My contribution is tested and working as described.
  • I have read the Developer Certificate of Origin and certify my contribution under its conditions.

jibsta210 and others added 4 commits April 25, 2026 14:03
Adds a "dim" stage to cosmic-idle that runs before the existing screen-off
stage. After a configurable inactivity period (separate idle notification),
the backlight fades smoothly down to a low brightness (default 7%) over
~300ms. Any input restores it. When the next idle stage powers the screen
off, we snap-restore the backlight to its pre-dim value *after* the screen
goes dark so that when the user later unlocks, the hardware is already at
the original brightness rather than stuck at 7%.

Why a separate signal path:
* dim writes go through logind's Session.SetBrightness, not the
  cosmic-settings-daemon DisplayBrightness property. SetBrightness writes
  the kernel sysfs value directly without firing the property-change signal
  that the OSD listens to, so the brightness slider doesn't pop up every
  time the screen is about to sleep. (This pairs with the hotkey-only
  brightness signal patch in cosmic-osd / cosmic-settings-daemon.)
* `snap_restore` is called from `fade_done` after the screen has been
  switched to Off, so the user never sees the snap.

Files:
* `src/dim.rs` (new): DimState — tracks the pre-dim brightness, drives
  the fade, talks to logind.
* `src/main.rs`: dim_idle_notification + dim_state fields, update_dim_idle
  handler, snap_restore call in fade_done.
* `cosmic-idle-config/src/lib.rs`: dim_idle config keys (timeout,
  target percent, fade duration).

Defaults: 7% target, 300ms fade.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…s HDR state across the timeout cycle

cosmic-idle previously called `output_power.set_mode(Off)` on the wlr
output_power_v1 protocol at the end of its fade-to-black animation.
On cosmic-comp that routed through ThreadCommand::DpmsOff →
DrmCompositor::clear() → atomic commit disabling CRTC + planes +
framebuffer attachment. On Intel xe + Tandem OLED with HDR enabled,
the recovery atomic commit on wake-up failed (kernel atomic_check
rejected the re-enable because the CRTC color pipeline blob refs
became stale), leaving the panel stuck on a black/garbled screen
that required a hard reboot.

Manual `loginctl lock-session` worked fine because it bypasses the
DPMS path entirely — the lockscreen renders on a healthy compositor.
The bug was strictly cosmic-idle's pre-lock screen-off step.

Replace the DPMS-off with a direct backlight write to 0 via
systemd-logind's Session.SetBrightness (same path dim.rs already
uses for the dim-on-idle feature). The panel goes physically dark
because drive current is 0 (LCD: backlight LED off; OLED: pixel
drive current = 0 → zero light emitted), but everything on the
software side stays running:

  - CRTC + planes + primary framebuffer stay attached
  - HDR signaling state (Colorspace, HDR_OUTPUT_METADATA) untouched
  - Hardware color pipeline blobs (DEGAMMA_LUT, CTM, GAMMA_LUT) all
    still committed on the CRTC
  - cosmic-comp keeps rendering frames (small extra power cost
    vs deep DPMS, but acceptable: that's the whole point of
    "screen off" not being "suspend")

Wake path:
  - update_screen_off_idle(false) reads `saved_panel_brightness`
    and writes it back via SetBrightness — instant snap, panel
    visibly comes back to its pre-screen-off level.
  - dim_state.restore() then animates from there to full original
    brightness if dim was active before screen-off (existing path,
    unchanged behavior).
  - The fade-to-black layer-shell surface is destroyed (existing,
    unchanged).

A new field `saved_panel_brightness: Option<u32>` on State covers
the no-dim case — without it, a user who disabled dim would wake
with brightness still at 0 (dim_state.restore would be a no-op
since dim_state has no original_brightness).

Side effects from removing snap_restore: dim_state stays "active"
across the screen-off period, so dim_state.restore on wake animates
brightness from current (0) up to the user's pre-dim original. This
gives a smoother visual recovery than the old snap_restore-then-DPMS
sequence.

dim::read_backlight and dim::set_brightness_via_logind are now
pub(crate) so main.rs can reuse them without code duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously cosmic-idle locked the session 500ms after `fade_done`
fired (i.e. right after the fade-to-black overlay finished animating
in). cosmic-greeter would spawn while the panel was at backlight=0,
PAM auth would start, and Howdy face-auth would fire blind — the
panel was off, so the camera saw nothing usable, and Howdy timed
out (default ~5 seconds) before the user got anywhere near the
laptop. By the time the user came back and woke the screen, Howdy
had already failed, PAM had fallen through to password, and the
lockscreen was sitting in password-only mode. Howdy was effectively
useless on every idle-triggered lock.

New flow:
  - fade_done sets `lock_on_wake = true` and writes backlight=0.
    NO lock_screen call yet, NO lock-session timer.
  - update_screen_off_idle(false) on wake:
      1. restore backlight to saved value (panel comes on)
      2. if lock_on_wake: call lock_screen() → cosmic-greeter
         spawns, PAM auth starts, Howdy fires *now* with the panel
         on and the user actually looking at the camera
      3. schedule fade_surface destroy in 500ms (after greeter has
         had time to put up its session-lock surface)

The fade-to-black overlay is now KEPT ALIVE through the off period
AND the wake-to-lockscreen transition (~100-500ms while
cosmic-greeter spins up). Without that, the wake would briefly
expose the unlocked desktop between backlight restore and lockscreen
appearing — security regression. With it, the user sees only:
opaque black overlay → lockscreen, never a flash of desktop.

The 500ms fade_surface destroy delay is conservative; cosmic-greeter
typically takes ~100-200ms. Extra 300ms of black-overlay-under-
lockscreen is invisible (lock surface is above layer-shell Overlay).

Manual lock paths (loginctl lock-session via shortcut, etc) are
unchanged — they don't go through this code at all.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Previously fade_done unconditionally saved current brightness into
saved_panel_brightness before writing 0. If dim was active when the
screen-off timeout fired, that saved value was the *dimmed* level
(7% of original by default).

On wake, two restore paths fire and race:
  1. dim_idle's resumed event → update_dim_idle(false) →
     dim_state.restore → spawns fade_thread that animates panel
     from 0 → original brightness (e.g. 0 → 1280)
  2. screen_off_idle's resumed event → update_screen_off_idle(false)
     → write saved_panel_brightness (the dimmed value, e.g. 26)

Order of resumed events from the wayland server isn't guaranteed.
If (1) fires and completes before (2), the panel ramps up to full,
then (2) snaps it back down to dim. End state: stuck dim, requires
manual wiggle to undim. (User-reproducible bug.)

Fix: only save in fade_done if dim is NOT already active. When
dim is active, dim_state already has the user's pre-dim original
and dim_state.restore on wake handles the full restore. With the
save skipped:
  - dim active path: only dim_state.restore writes brightness
  - no-dim path: only saved_panel_brightness write happens
  - never both writing to the same panel from different threads

Tested on Tandem OLED + intel_backlight + cosmic-idle's default
30s/120s/7% timings. No more stuck-dim wake.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant