atagen

monolith

public
Star0
Watch0

Commit 9b87063809e1

tarball

atagen <boss@atagen.co>2026-10-17 13:29 UTC

4 files changed+134-14
monolith: configure output rotation, position and mode per connector

Outputs were always unrotated, at their preferred mode and lined up left to right in connection order, so a portrait monitor could not be used. The settings apply when the output connects, since a new mode or rotation needs a modeset.
Modified file: crates/config/default.toml+8 additions
@@ -34,6 +34,14 @@ unfocused = "#3b4252"
3434 # Variable refresh rate on outputs that support it, off, on, or fullscreen.
3535 vrr = "fullscreen"
3636
37+# Per connector, read when the output connects. transform is normal, 90, 180,
38+# 270, flipped, flipped-90, flipped-180 or flipped-270. Outputs without a
39+# position line up left to right.
40+# [output.DP-1]
41+# transform = "90"
42+# position = [2560, 0]
43+# mode = "1920x1080@60"
44+
3745 [animation]
3846 enabled = true
3947 # orthogonal moves each window along one axis per phase, direct allows both.
Modified file: crates/config/src/lib.rs+47 additions−2 deletions
@@ -78,9 +78,47 @@ impl Default for Gaps {
7878 }
7979
8080 #[derive(Deserialize, Debug, Clone, PartialEq, Default)]
81-#[serde(default, deny_unknown_fields)]
81+#[serde(default)]
8282 pub struct Outputs {
8383 pub vrr: Vrr,
84+ /// Settings for one connector, by name, like `DP-1`.
85+ #[serde(flatten)]
86+ pub named: std::collections::BTreeMap<String, OutputConfig>,
87+}
88+
89+/// Applied when the output connects. Changing them needs a restart or a
90+/// replug, since a new mode or rotation means a modeset.
91+#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
92+#[serde(default, deny_unknown_fields)]
93+pub struct OutputConfig {
94+ pub transform: OutputTransform,
95+ /// Top left corner in the global layout. Outputs without one are placed
96+ /// to the right of the others.
97+ pub position: Option<[i32; 2]>,
98+ /// `WIDTHxHEIGHT` or `WIDTHxHEIGHT@HZ`. Without a rate the highest one
99+ /// for that size wins.
100+ pub mode: Option<String>,
101+}
102+
103+/// Counterclockwise rotation, optionally after flipping horizontally.
104+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
105+#[serde(rename_all = "kebab-case")]
106+pub enum OutputTransform {
107+ #[default]
108+ Normal,
109+ #[serde(rename = "90")]
110+ Rotate90,
111+ #[serde(rename = "180")]
112+ Rotate180,
113+ #[serde(rename = "270")]
114+ Rotate270,
115+ Flipped,
116+ #[serde(rename = "flipped-90")]
117+ Flipped90,
118+ #[serde(rename = "flipped-180")]
119+ Flipped180,
120+ #[serde(rename = "flipped-270")]
121+ Flipped270,
84122 }
85123
86124 #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -568,6 +606,11 @@ mod tests {
568606 [binds]
569607 "Mod+Return" = "spawn beer"
570608 "Hyper+x" = "close"
609+ [output.DP-1]
610+ transform = "90"
611+ position = [0, 0]
612+ [output.DP-2]
613+ transform = "sideways"
571614 [[rules]]
572615 app_id = "("
573616 [[rules]]
@@ -582,6 +625,8 @@ mod tests {
582625 assert_eq!(c.decoration.radius, Corners([1, 2, 3, 4]));
583626 assert_eq!(c.decoration.focused, Decoration::default().focused);
584627 assert_eq!((c.binds.len(), c.rules.len()), (1, 1));
585- assert_eq!(loaded.warnings.len(), 5, "{:#?}", loaded.warnings);
628+ assert_eq!(c.output.named["DP-1"].transform, OutputTransform::Rotate90);
629+ assert!(!c.output.named.contains_key("DP-2"));
630+ assert_eq!(loaded.warnings.len(), 6, "{:#?}", loaded.warnings);
586631 }
587632 }
Modified file: crates/monolith/src/drm.rs+78 additions−12 deletions
@@ -7,7 +7,7 @@ use crate::scene::Draw;
77 use crate::state::Services;
88 use crate::state::{State, now_ns};
99 use crate::wm::Wm;
10-use monolith_config::Vrr;
10+use monolith_config::{OutputTransform, Vrr};
1111 use monolith_layout::{OutputId, Rect};
1212 use smithay::backend::allocator::Fourcc;
1313 use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
@@ -31,7 +31,7 @@ use smithay::output::{Mode, Output, PhysicalProperties, Scale, Subpixel};
3131 use smithay::reexports::calloop::timer::{TimeoutAction, Timer};
3232 use smithay::reexports::calloop::{EventLoop, LoopHandle, RegistrationToken};
3333 use smithay::reexports::drm::Device as _;
34-use smithay::reexports::drm::control::{ModeTypeFlags, connector, crtc};
34+use smithay::reexports::drm::control::{self, ModeTypeFlags, connector, crtc};
3535 use smithay::reexports::input::Libinput;
3636 use smithay::reexports::rustix::fs::OFlags;
3737 use smithay::utils::{DeviceFd, Point, Transform};
@@ -265,15 +265,33 @@ fn connect(state: &mut State, connector: connector::Info, crtc: crtc::Handle) {
265265 connector.interface().as_str(),
266266 connector.interface_id()
267267 );
268- let Some(&mode) = connector
269- .modes()
270- .iter()
271- .find(|m| m.mode_type().contains(ModeTypeFlags::PREFERRED))
272- .or_else(|| connector.modes().first())
273- else {
268+ let config = state
269+ .settings
270+ .config
271+ .output
272+ .named
273+ .get(&name)
274+ .cloned()
275+ .unwrap_or_default();
276+ let chosen = config.mode.as_deref().and_then(|spec| {
277+ let found = pick_mode(connector.modes(), spec);
278+ if found.is_none() {
279+ tracing::warn!(name, spec, "no such mode, using the preferred one");
280+ }
281+ found
282+ });
283+ let Some(mode) = chosen.or_else(|| {
284+ connector
285+ .modes()
286+ .iter()
287+ .find(|m| m.mode_type().contains(ModeTypeFlags::PREFERRED))
288+ .or_else(|| connector.modes().first())
289+ .copied()
290+ }) else {
274291 tracing::warn!(name, "connector has no modes");
275292 return;
276293 };
294+ let transform = transform(config.transform);
277295 let State {
278296 backend: Backend::Drm(drm),
279297 renderer,
@@ -296,14 +314,17 @@ fn connect(state: &mut State, connector: connector::Info, crtc: crtc::Handle) {
296314 },
297315 );
298316 let wl_mode = Mode::from(mode);
299- let x = layout
317+ let right = layout
300318 .outputs()
301319 .map(|(_, o)| o.area.right())
302320 .fold(0.0, f64::max);
303- let position = Point::from((x as i32, 0));
321+ let position = match config.position {
322+ Some([x, y]) => Point::from((x, y)),
323+ None => Point::from((right as i32, 0)),
324+ };
304325 output.change_current_state(
305326 Some(wl_mode),
306- Some(Transform::Normal),
327+ Some(transform),
307328 Some(Scale::Integer(1)),
308329 Some(position),
309330 );
@@ -337,13 +358,24 @@ fn connect(state: &mut State, connector: connector::Info, crtc: crtc::Handle) {
337358 );
338359 space.map_output(&output, position);
339360 let (w, h) = mode.size();
340- let area = Rect::new(x, 0.0, f64::from(w), f64::from(h));
361+ let size = transform.transform_size(
362+ smithay::utils::Size::<i32, smithay::utils::Logical>::from((i32::from(w), i32::from(h))),
363+ );
364+ let area = Rect::new(
365+ f64::from(position.x),
366+ f64::from(position.y),
367+ f64::from(size.w),
368+ f64::from(size.h),
369+ );
341370 let id = layout.add_output(&name, area);
342371 tracing::info!(
343372 name,
344373 w,
345374 h,
346375 refresh = mode.vrefresh(),
376+ ?transform,
377+ x = position.x,
378+ y = position.y,
347379 vrr_capable,
348380 "output connected"
349381 );
@@ -372,6 +404,40 @@ fn connect(state: &mut State, connector: connector::Info, crtc: crtc::Handle) {
372404 );
373405 }
374406
407+/// The mode matching `WIDTHxHEIGHT` or `WIDTHxHEIGHT@HZ`, the highest rate
408+/// when none is given and the nearest one when it is.
409+fn pick_mode(modes: &[control::Mode], spec: &str) -> Option<control::Mode> {
410+ let (size, rate) = match spec.split_once('@') {
411+ Some((size, rate)) => (size, Some(rate.trim().parse::<f64>().ok()?)),
412+ None => (spec, None),
413+ };
414+ let (w, h) = size.trim().split_once('x')?;
415+ let size = (w.trim().parse::<u16>().ok()?, h.trim().parse::<u16>().ok()?);
416+ let fitting = modes.iter().filter(|m| m.size() == size);
417+ match rate {
418+ Some(r) => fitting
419+ .min_by(|a, b| {
420+ let off = |m: &&control::Mode| (f64::from(m.vrefresh()) - r).abs();
421+ off(a).total_cmp(&off(b))
422+ })
423+ .copied(),
424+ None => fitting.max_by_key(|m| m.vrefresh()).copied(),
425+ }
426+}
427+
428+fn transform(t: OutputTransform) -> Transform {
429+ match t {
430+ OutputTransform::Normal => Transform::Normal,
431+ OutputTransform::Rotate90 => Transform::_90,
432+ OutputTransform::Rotate180 => Transform::_180,
433+ OutputTransform::Rotate270 => Transform::_270,
434+ OutputTransform::Flipped => Transform::Flipped,
435+ OutputTransform::Flipped90 => Transform::Flipped90,
436+ OutputTransform::Flipped180 => Transform::Flipped180,
437+ OutputTransform::Flipped270 => Transform::Flipped270,
438+ }
439+}
440+
375441 fn disconnect(state: &mut State, crtc: crtc::Handle) {
376442 let Some(drm) = state.backend.drm_mut() else {
377443 return;
Modified file: design/test-checklist.md+1 additions
@@ -33,6 +33,7 @@ nix develop -c sh -c 'RUST_LOG=monolith=debug target/release/monolith run --back
3333 - [ ] **vkcube black start.** Is the half second of black at launch gone?
3434 - [ ] **Fullscreen switches.** Toggle fullscreen on vkcube a few times and look for `slow frame` lines. First-time imports now run on a worker thread.
3535 - [ ] **Direct scanout.** Fullscreen vkcube should log `direct scanout scanout=true`, and its `frame rate` lines should show `scanned_out` close to `presented`. If it logs `scanout=false` or never switches, the `elements`, `overlay_planes` and `cursor_plane` fields on that line show what was on screen with it.
36+- [ ] **Output rotation.** `[output.DP-1] transform = "90"` with a `position` puts DP-1 on its side where you placed it. The `output connected` log line shows the transform and position. The pointer should cross between outputs where their edges meet.
3637 - [ ] **CPU target.** `cpu_p99_us` on the `frame rate` lines should stay under 500, the target in decisions.md.
3738 - [ ] **NixOS module.** Add the flake as an input, import `monolith.nixosModules.default` and set `programs.monolith.enable = true`. A `monolith` session appears in the display manager. With the default `realtime-scheduling = true`, the log should show `compositor thread runs SCHED_RR` and `queue global priority priority=Some(REALTIME)`. Programs launched from monolith should not inherit CAP_SYS_NICE, which `grep Cap /proc/(pgrep beer)/status` shows as an all-zero `CapAmb`.