atagen

monolith

public
Star0
Watch0

Commit 5b8422a225da

tarball

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

4 files changed+86-4
power: handle the lid switch

`on_input` dropped libinput switch events, so closing a laptop lid did nothing in monolith. `[lid] close` picks suspend, poweroff or ignore through systemctl. With another output connected the lid only darkens the eDP, LVDS or DSI panel, like logind's docked case, and the action runs once the last external output goes.
Modified file: crates/config/default.toml+8 additions
@@ -55,6 +55,14 @@ curve = [0.45, 0.0, 0.2, 1.0]
5555 # keep the screen on while they are shown.
5656 blank_after = 0
5757
58+[lid]
59+# What closing the lid does, suspend, poweroff or ignore. The internal panel
60+# (eDP, LVDS) switches off whenever the lid is closed and another output is
61+# connected, and then neither suspend nor poweroff happens. Without a
62+# handle-lid-switch inhibitor, logind still acts on the lid itself, so set
63+# HandleLidSwitch=ignore there for ignore to mean anything.
64+close = "suspend"
65+
5866 [keyboard]
5967 layout = ""
6068 repeat_delay = 200
Modified file: crates/config/src/lib.rs+21 additions
@@ -18,6 +18,7 @@ pub struct Config {
1818 pub keyboard: Keyboard,
1919 pub input: Input,
2020 pub idle: Idle,
21+ pub lid: Lid,
2122 pub binds: Vec<Bind>,
2223 pub rules: Vec<Rule>,
2324 pub startup: Vec<Vec<String>>,
@@ -244,6 +245,23 @@ pub struct Idle {
244245 pub blank_after: u64,
245246 }
246247
248+#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
249+#[serde(default, deny_unknown_fields)]
250+pub struct Lid {
251+ /// What closing the lid does while no other output is connected.
252+ pub close: LidAction,
253+}
254+
255+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
256+#[serde(rename_all = "lowercase")]
257+pub enum LidAction {
258+ #[default]
259+ Suspend,
260+ Poweroff,
261+ /// Leave it to logind or whatever else watches the switch.
262+ Ignore,
263+}
264+
247265 #[derive(Deserialize, Debug, Clone, PartialEq)]
248266 #[serde(default, deny_unknown_fields)]
249267 pub struct Keyboard {
@@ -522,6 +540,7 @@ impl Config {
522540 let keyboard = section("keyboard", table.remove("keyboard"), w);
523541 let input = input(table.remove("input"), w);
524542 let idle = section("idle", table.remove("idle"), w);
543+ let lid = section("lid", table.remove("lid"), w);
525544 let animation = section("animation", table.remove("animation"), w);
526545 let decoration = section("decoration", table.remove("decoration"), w);
527546 let output: Outputs = section("output", table.remove("output"), w);
@@ -582,6 +601,7 @@ impl Config {
582601 keyboard,
583602 input,
584603 idle,
604+ lid,
585605 binds,
586606 rules,
587607 startup,
@@ -771,6 +791,7 @@ mod tests {
771791 assert_eq!(c.keyboard, Keyboard::default());
772792 assert_eq!(c.input, Input::default());
773793 assert_eq!(c.idle, Idle::default());
794+ assert_eq!(c.lid, Lid::default());
774795 assert!(c.binds.len() > 40);
775796 }
776797
Modified file: crates/monolith/src/drm.rs+7 additions
@@ -34,6 +34,7 @@ use smithay::reexports::calloop::{EventLoop, LoopHandle, RegistrationToken};
3434 use smithay::reexports::drm::Device as _;
3535 use smithay::reexports::drm::control::{self, ModeTypeFlags, connector, crtc};
3636 use smithay::reexports::input::Libinput;
37+use smithay::reexports::input::event::switch::{Switch, SwitchState};
3738 use smithay::reexports::rustix::fs::OFlags;
3839 use smithay::reexports::wayland_server::backend::GlobalId;
3940 use smithay::utils::{DeviceFd, Logical, Point, Transform};
@@ -318,6 +319,7 @@ fn scan(state: &mut State) {
318319 retry_connector(state, handle);
319320 }
320321 state.apply_power();
322+ state.lid_act();
321323 state.apply_layout();
322324 }
323325
@@ -1146,6 +1148,11 @@ fn on_input(state: &mut State, event: InputEvent<LibinputInputBackend>) {
11461148 state.pointer_axis(frame);
11471149 state.pointer_frame();
11481150 }
1151+ InputEvent::SwitchToggle { event } => {
1152+ if event.switch() == Some(Switch::Lid) {
1153+ state.lid_changed(event.switch_state() == SwitchState::On);
1154+ }
1155+ }
11491156 InputEvent::DeviceAdded { device } => {
11501157 if let Some(drm) = state.backend.drm_mut() {
11511158 drm.devices.add(device, &state.settings.config.input);
Modified file: crates/monolith/src/power.rs+50 additions−4 deletions
@@ -3,6 +3,7 @@
33 //! came out.
44
55 use crate::state::{State, now_ns};
6+use monolith_config::LidAction;
67 use monolith_protocols::output_power::{OutputPowerHandler, OutputPowerState};
78 use smithay::output::Output;
89 use smithay::reexports::calloop::RegistrationToken;
@@ -16,13 +17,19 @@ pub struct Power {
1617 idle_blanked: bool,
1718 /// Outputs switched off by name, which input does not wake.
1819 off: HashSet<String>,
20+ lid_closed: bool,
21+ /// The lid close action already ran for this closing.
22+ lid_acted: bool,
23+ /// The internal panel is dark because the lid is shut and something else
24+ /// is connected.
25+ panel_off: bool,
1926 last_activity: u64,
2027 timer: Option<RegistrationToken>,
2128 }
2229
2330 impl Power {
2431 pub fn dark(&self, name: &str) -> bool {
25- self.idle_blanked || self.off.contains(name)
32+ self.idle_blanked || self.off.contains(name) || (self.panel_off && internal(name))
2633 }
2734
2835 /// An inhibitor going away counts as activity, so the screen does not
@@ -36,6 +43,12 @@ impl Power {
3643 }
3744 }
3845
46+fn internal(name: &str) -> bool {
47+ ["eDP-", "LVDS-", "DSI-"]
48+ .iter()
49+ .any(|p| name.starts_with(p))
50+}
51+
3952 impl OutputPowerHandler for State {
4053 fn output_power_state(&mut self) -> &mut OutputPowerState {
4154 &mut self.protocols.output_power
@@ -82,6 +95,8 @@ impl State {
8295 /// Pushes the wanted power state of every output to the backend.
8396 pub fn apply_power(&mut self) {
8497 let names: Vec<String> = self.wm.space.outputs().map(|o| o.name()).collect();
98+ let power = &mut self.services.power;
99+ power.panel_off = power.lid_closed && names.iter().any(|n| !internal(n));
85100 for name in names {
86101 let on = self.output_powered(&name);
87102 if let Some(drm) = self.backend.drm_mut() {
@@ -89,9 +104,7 @@ impl State {
89104 }
90105 }
91106 let power = &self.services.power;
92- self.protocols
93- .output_power
94- .sync(|o| !power.dark(&o.name()));
107+ self.protocols.output_power.sync(|o| !power.dark(&o.name()));
95108 }
96109
97110 /// Notes input, and lights the outputs again if idleness had blanked them.
@@ -105,6 +118,39 @@ impl State {
105118 }
106119 }
107120
121+ /// The lid switch moved. Closing it darkens the internal panel when
122+ /// something else shows the desktop, and otherwise runs the configured
123+ /// action. Opening it counts as input.
124+ pub fn lid_changed(&mut self, closed: bool) {
125+ tracing::info!(closed, "lid");
126+ self.services.power.lid_closed = closed;
127+ self.services.power.lid_acted = false;
128+ if !closed {
129+ self.activity();
130+ }
131+ self.apply_power();
132+ self.lid_act();
133+ }
134+
135+ /// Runs the lid close action if the lid is shut with no other output.
136+ /// Also called when outputs come and go, since unplugging the last
137+ /// external display of a closed laptop is when it should sleep.
138+ pub fn lid_act(&mut self) {
139+ let power = &mut self.services.power;
140+ if !power.lid_closed || power.lid_acted || power.panel_off {
141+ return;
142+ }
143+ power.lid_acted = true;
144+ let verb = match self.settings.config.lid.close {
145+ LidAction::Suspend => "suspend",
146+ LidAction::Poweroff => "poweroff",
147+ LidAction::Ignore => return,
148+ };
149+ if let Err(e) = self.spawn(&["systemctl".into(), verb.into()]) {
150+ tracing::warn!("lid close action failed: {e}");
151+ }
152+ }
153+
108154 fn wake_idle(&mut self) {
109155 if std::mem::take(&mut self.services.power.idle_blanked) {
110156 tracing::info!("input after idle, outputs back on");