atagen

monolith

public
Star0
Watch0

Commit 47b86d08d05a

tarball

atagen <boss@atagen.co>2026-10-19 07:51 UTC

9 files changed+196-12
power: blank every output after an idle timeout

ext-idle-notify only tells clients the user left, so nothing in monolith itself turned the panels off without swayidle. `[idle] blank_after` now sets DPMS off through `DrmCompositor::clear`, which keeps the surface so the next queued frame re-enables it without a reconnect. Input wakes the outputs and idle inhibitors hold the blank off.
Modified file: crates/config/default.toml+6 additions
@@ -49,6 +49,12 @@ style = "orthogonal"
4949 phase_ms = 160
5050 curve = [0.45, 0.0, 0.2, 1.0]
5151
52+[idle]
53+# Seconds without input before every output is switched off, any input
54+# switches them back on. 0 never blanks. Windows holding an idle inhibitor
55+# keep the screen on while they are shown.
56+blank_after = 0
57+
5258 [keyboard]
5359 layout = ""
5460 repeat_delay = 200
Modified file: crates/config/src/lib.rs+12 additions
@@ -17,6 +17,7 @@ pub struct Config {
1717 pub animation: Animation,
1818 pub keyboard: Keyboard,
1919 pub input: Input,
20+ pub idle: Idle,
2021 pub binds: Vec<Bind>,
2122 pub rules: Vec<Rule>,
2223 pub startup: Vec<Vec<String>>,
@@ -235,6 +236,14 @@ pub enum AnimationStyle {
235236 Direct,
236237 }
237238
239+#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
240+#[serde(default, deny_unknown_fields)]
241+pub struct Idle {
242+ /// Seconds without input before every output is switched off, 0 for
243+ /// never.
244+ pub blank_after: u64,
245+}
246+
238247 #[derive(Deserialize, Debug, Clone, PartialEq)]
239248 #[serde(default, deny_unknown_fields)]
240249 pub struct Keyboard {
@@ -512,6 +521,7 @@ impl Config {
512521 let general: General = section("general", table.remove("general"), w);
513522 let keyboard = section("keyboard", table.remove("keyboard"), w);
514523 let input = input(table.remove("input"), w);
524+ let idle = section("idle", table.remove("idle"), w);
515525 let animation = section("animation", table.remove("animation"), w);
516526 let decoration = section("decoration", table.remove("decoration"), w);
517527 let output: Outputs = section("output", table.remove("output"), w);
@@ -571,6 +581,7 @@ impl Config {
571581 output,
572582 keyboard,
573583 input,
584+ idle,
574585 binds,
575586 rules,
576587 startup,
@@ -759,6 +770,7 @@ mod tests {
759770 assert_eq!(c.animation, Animation::default());
760771 assert_eq!(c.keyboard, Keyboard::default());
761772 assert_eq!(c.input, Input::default());
773+ assert_eq!(c.idle, Idle::default());
762774 assert!(c.binds.len() > 40);
763775 }
764776
Modified file: crates/monolith/src/commands.rs+1 additions
@@ -229,6 +229,7 @@ impl State {
229229 drm.devices.reconfigure(&self.settings.config.input);
230230 }
231231 crate::drm::reconfigure_outputs(self);
232+ self.configure_idle();
232233 tracing::info!("config reloaded");
233234 Ok(())
234235 }
Modified file: crates/monolith/src/drm.rs+45 additions−2 deletions
@@ -83,6 +83,8 @@ struct Surface {
8383 /// A frame is on its way to the screen and its vblank has not landed.
8484 queued: bool,
8585 queued_at: u64,
86+ /// The panel is lit. Off outputs are not rendered to.
87+ powered: bool,
8688 /// Fires when a queued frame's vblank never arrives.
8789 watchdog: Option<RegistrationToken>,
8890 /// Consecutive frames that failed to render or queue.
@@ -221,6 +223,11 @@ pub fn init(
221223 s.target_ns = None;
222224 s.failures = 0;
223225 s.redraw = true;
226+ if !s.powered
227+ && let Err(e) = s.drm.with_compositor(|c| c.clear())
228+ {
229+ tracing::warn!("could not keep the output off: {e}");
230+ }
224231 }
225232 }
226233 scan(state);
@@ -310,6 +317,7 @@ fn scan(state: &mut State) {
310317 for handle in retry {
311318 retry_connector(state, handle);
312319 }
320+ state.apply_power();
313321 state.apply_layout();
314322 }
315323
@@ -471,6 +479,7 @@ fn connect(state: &mut State, connector: connector::Info, crtc: crtc::Handle) {
471479 drm: out,
472480 queued: false,
473481 queued_at: 0,
482+ powered: true,
474483 watchdog: None,
475484 failures: 0,
476485 redraw: true,
@@ -622,9 +631,43 @@ fn disconnect(state: &mut State, crtc: crtc::Handle) {
622631 state.wm.layout.remove_output(surface.id);
623632 state.close_layers(&surface.output);
624633 state.services.lock.forget(&surface.output);
634+ state.services.power.forget(&surface.output.name());
625635 }
626636
627637 impl Drm {
638+ /// Lights or darkens the output called `name`. Dark outputs get DPMS off
639+ /// and no frames, and the next frame after turning on is a full modeset.
640+ pub fn set_powered(&mut self, name: &str, on: bool) {
641+ let active = self.session.is_active();
642+ for s in self
643+ .surfaces
644+ .values_mut()
645+ .filter(|s| s.output.name() == name)
646+ {
647+ if s.powered == on {
648+ continue;
649+ }
650+ s.powered = on;
651+ tracing::info!(name, on, "output power");
652+ if on {
653+ s.redraw = true;
654+ continue;
655+ }
656+ for token in [s.scheduled.take(), s.watchdog.take()]
657+ .into_iter()
658+ .flatten()
659+ {
660+ self.handle.remove(token);
661+ }
662+ s.queued = false;
663+ s.target_ns = None;
664+ s.cursor_redraw = false;
665+ if active && let Err(e) = s.drm.with_compositor(|c| c.clear()) {
666+ tracing::warn!(name, "could not switch the output off: {e}");
667+ }
668+ }
669+ }
670+
628671 pub fn output(&self, id: OutputId) -> Option<Output> {
629672 self.surfaces
630673 .values()
@@ -713,7 +756,7 @@ fn schedule(state: &mut State, crtc: crtc::Handle) {
713756 let Some(surface) = drm.surfaces.get_mut(&crtc) else {
714757 return;
715758 };
716- if surface.queued || surface.scheduled.is_some() {
759+ if !surface.powered || surface.queued || surface.scheduled.is_some() {
717760 return;
718761 }
719762 let now = now_ns();
@@ -918,7 +961,7 @@ fn render_surface(state: &mut State, crtc: crtc::Handle, target: u64) -> bool {
918961 let Some(surface) = drm.surfaces.get_mut(&crtc) else {
919962 return false;
920963 };
921- if surface.queued {
964+ if surface.queued || !surface.powered {
922965 return false;
923966 }
924967 surface.cursor_redraw = false;
Modified file: crates/monolith/src/idle.rs+3 additions−5 deletions
@@ -45,11 +45,6 @@ impl IdleInhibitHandler for State {
4545 }
4646
4747 impl State {
48- pub fn activity(&mut self) {
49- let seat = self.input.seat.clone();
50- self.services.idle.notifier.notify_activity(&seat);
51- }
52-
5348 /// An inhibitor only counts while its surface is on screen, as the
5449 /// protocol asks, so a video on a hidden tag lets the screen blank.
5550 pub fn update_inhibit(&mut self) {
@@ -67,6 +62,9 @@ impl State {
6762 });
6863 if shown != self.services.idle.inhibited {
6964 self.services.idle.inhibited = shown;
65+ if !shown {
66+ self.services.power.touch();
67+ }
7068 self.services.idle.notifier.set_is_inhibited(shown);
7169 }
7270 }
Modified file: crates/monolith/src/lock.rs+4 additions−5 deletions
@@ -108,11 +108,10 @@ impl State {
108108 if self.services.lock.pending.is_none() {
109109 return;
110110 }
111- let all = self
112- .wm
113- .space
114- .outputs()
115- .all(|o| self.services.lock.blanked.contains(&o.name()));
111+ let all = self.wm.space.outputs().all(|o| {
112+ let name = o.name();
113+ self.services.lock.blanked.contains(&name) || !self.output_powered(&name)
114+ });
116115 if all && let Some(locker) = self.services.lock.pending.take() {
117116 locker.lock();
118117 self.services.lock.locked = true;
Modified file: crates/monolith/src/main.rs+2 additions
@@ -19,6 +19,7 @@ mod layers;
1919 mod lock;
2020 mod nested;
2121 mod pacing;
22+mod power;
2223 mod scene;
2324 mod seat;
2425 mod state;
@@ -264,6 +265,7 @@ fn run(
264265 }
265266 ipc::init(&event_loop, &mut state)?;
266267 xwayland::init(&mut state);
268+ state.configure_idle();
267269 tracing::info!(socket = ?state.core.socket_name, device = state.renderer.device_name(), "monolith running");
268270
269271 if gpu.is_some() {
Added file: crates/monolith/src/power.rs+121 additions
@@ -0,0 +1,121 @@
1+//! Whether each output is lit. Idle blanking, the ipc `power` command and
2+//! wlr-output-power-management all end up here, and the backend is told what
3+//! came out.
4+
5+use crate::state::{State, now_ns};
6+use smithay::reexports::calloop::RegistrationToken;
7+use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
8+use std::collections::HashSet;
9+use std::time::Duration;
10+
11+#[derive(Default)]
12+pub struct Power {
13+ /// Every output is dark until the next input.
14+ idle_blanked: bool,
15+ /// Outputs switched off by name, which input does not wake.
16+ off: HashSet<String>,
17+ last_activity: u64,
18+ timer: Option<RegistrationToken>,
19+}
20+
21+impl Power {
22+ pub fn dark(&self, name: &str) -> bool {
23+ self.idle_blanked || self.off.contains(name)
24+ }
25+
26+ /// An inhibitor going away counts as activity, so the screen does not
27+ /// blank the moment a video stops.
28+ pub fn touch(&mut self) {
29+ self.last_activity = now_ns();
30+ }
31+
32+ pub fn forget(&mut self, name: &str) {
33+ self.off.remove(name);
34+ }
35+}
36+
37+impl State {
38+ pub fn output_powered(&self, name: &str) -> bool {
39+ !self.services.power.dark(name)
40+ }
41+
42+ /// Pushes the wanted power state of every output to the backend.
43+ pub fn apply_power(&mut self) {
44+ let names: Vec<String> = self.wm.space.outputs().map(|o| o.name()).collect();
45+ for name in names {
46+ let on = self.output_powered(&name);
47+ if let Some(drm) = self.backend.drm_mut() {
48+ drm.set_powered(&name, on);
49+ }
50+ }
51+ }
52+
53+ /// Notes input, and lights the outputs again if idleness had blanked them.
54+ pub fn activity(&mut self) {
55+ let seat = self.input.seat.clone();
56+ self.services.idle.notifier.notify_activity(&seat);
57+ self.services.power.touch();
58+ if self.services.power.idle_blanked {
59+ self.wake_idle();
60+ self.apply_power();
61+ }
62+ }
63+
64+ fn wake_idle(&mut self) {
65+ if std::mem::take(&mut self.services.power.idle_blanked) {
66+ tracing::info!("input after idle, outputs back on");
67+ self.configure_idle();
68+ }
69+ }
70+
71+ /// Arms the idle timer from the config, or disarms it.
72+ pub fn configure_idle(&mut self) {
73+ if let Some(token) = self.services.power.timer.take() {
74+ self.core.handle.remove(token);
75+ }
76+ let secs = self.settings.config.idle.blank_after;
77+ if secs == 0 || self.services.power.idle_blanked {
78+ return;
79+ }
80+ let power = &mut self.services.power;
81+ if power.last_activity == 0 {
82+ power.touch();
83+ }
84+ let timeout = secs.saturating_mul(1_000_000_000);
85+ let left = timeout.saturating_sub(now_ns().saturating_sub(power.last_activity));
86+ let after = Timer::from_duration(Duration::from_nanos(left.max(1_000_000)));
87+ let token = self
88+ .core
89+ .handle
90+ .insert_source(after, |_, _, state| state.idle_elapsed());
91+ match token {
92+ Ok(token) => self.services.power.timer = Some(token),
93+ Err(e) => tracing::warn!("could not arm the idle timer: {e}"),
94+ }
95+ }
96+
97+ fn idle_elapsed(&mut self) -> TimeoutAction {
98+ let timeout = self
99+ .settings
100+ .config
101+ .idle
102+ .blank_after
103+ .saturating_mul(1_000_000_000);
104+ if timeout == 0 {
105+ self.services.power.timer = None;
106+ return TimeoutAction::Drop;
107+ }
108+ let idle_for = now_ns().saturating_sub(self.services.power.last_activity);
109+ if idle_for < timeout {
110+ return TimeoutAction::ToDuration(Duration::from_nanos(timeout - idle_for));
111+ }
112+ if self.services.idle.inhibited {
113+ return TimeoutAction::ToDuration(Duration::from_nanos(timeout));
114+ }
115+ tracing::info!("idle, switching outputs off");
116+ self.services.power.timer = None;
117+ self.services.power.idle_blanked = true;
118+ self.apply_power();
119+ TimeoutAction::Drop
120+ }
121+}
Modified file: crates/monolith/src/state.rs+2 additions
@@ -88,6 +88,7 @@ pub struct Services {
8888 pub launches: crate::launch::Launches,
8989 pub ipc: crate::ipc::Server,
9090 pub idle: crate::idle::Idle,
91+ pub power: crate::power::Power,
9192 pub commits: crate::stats::CommitStats,
9293 }
9394
@@ -199,6 +200,7 @@ impl State {
199200 launches: Default::default(),
200201 ipc: Default::default(),
201202 idle,
203+ power: Default::default(),
202204 commits: Default::default(),
203205 },
204206 })