atagen

monolith

public
Star0
Watch0

Commit fc167134b65d

tarball

atagen <boss@atagen.co>2026-10-19 08:38 UTC

2 files changed+69-8
config: add a per-output scale and allow [output.nested]

Output settings had no way to ask for a scale, and the nested backend could not be configured at all because its name tripped the connector check. Scale is rounded to the 1/120 steps wp_fractional_scale_v1 can express.
Modified file: crates/config/default.toml+8 additions−3 deletions
@@ -39,13 +39,18 @@ urgent = "#ebcb8b"
3939 # Variable refresh rate on outputs that support it, off, on, or fullscreen.
4040 vrr = "fullscreen"
4141
42-# Per connector. transform and position follow a reload, mode needs a replug.
43-# transform is normal, 90, 180, 270, flipped, flipped-90, flipped-180 or
44-# flipped-270. Outputs without a position line up left to right.
42+# Per connector, or [output.nested] for the nested backend's window. transform,
43+# position and scale follow a reload, mode needs a replug. transform is normal,
44+# 90, 180, 270, flipped, flipped-90, flipped-180 or flipped-270. Outputs without
45+# a position line up left to right. position is in logical pixels, the size of
46+# the output divided by its scale. scale is 0.5 to 4.0 and fractional values
47+# are rounded to the nearest 1/120. X11 clients are drawn at scale 1 and
48+# stretched.
4549 # [output.DP-1]
4650 # transform = "90"
4751 # position = [2560, 0]
4852 # mode = "1920x1080@60"
53+# scale = 1.5
4954
5055 [animation]
5156 enabled = true
Modified file: crates/config/src/lib.rs+61 additions−5 deletions
@@ -113,13 +113,14 @@ impl Default for Gaps {
113113 #[serde(default)]
114114 pub struct Outputs {
115115 pub vrr: Vrr,
116- /// Settings for one connector, by name, like `DP-1`.
116+ /// Settings for one connector, by name, like `DP-1`, or `nested` for the
117+ /// nested backend's window.
117118 #[serde(flatten)]
118119 pub named: std::collections::BTreeMap<String, OutputConfig>,
119120 }
120121
121-/// Transform and position follow a reload. A new mode waits for a restart
122-/// or a replug, since it means a modeset.
122+/// Transform, position and scale follow a reload. A new mode waits for a
123+/// restart or a replug, since it means a modeset.
123124 #[derive(Deserialize, Debug, Clone, PartialEq, Default)]
124125 #[serde(default, deny_unknown_fields)]
125126 pub struct OutputConfig {
@@ -130,8 +131,34 @@ pub struct OutputConfig {
130131 /// `WIDTHxHEIGHT` or `WIDTHxHEIGHT@HZ`. Without a rate the highest one
131132 /// for that size wins.
132133 pub mode: Option<String>,
134+ /// How many physical pixels make a logical one, `0.5` to `4.0` in steps of
135+ /// 1/120. Positions are in logical pixels.
136+ pub scale: Option<f64>,
133137 }
134138
139+impl OutputConfig {
140+ pub fn scale(&self) -> f64 {
141+ self.scale.unwrap_or(1.0)
142+ }
143+
144+ fn validate(&mut self, name: &str, w: &mut Vec<String>) {
145+ let Some(s) = self.scale else {
146+ return;
147+ };
148+ if !(MIN_SCALE..=MAX_SCALE).contains(&s) {
149+ w.push(format!(
150+ "{name}.scale: {s} is outside {MIN_SCALE} to {MAX_SCALE}, ignored"
151+ ));
152+ self.scale = None;
153+ } else {
154+ self.scale = Some((s * 120.0).round() / 120.0);
155+ }
156+ }
157+}
158+
159+const MIN_SCALE: f64 = 0.5;
160+const MAX_SCALE: f64 = 4.0;
161+
135162 /// Counterclockwise rotation, optionally after flipping horizontally.
136163 #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
137164 #[serde(rename_all = "kebab-case")]
@@ -576,12 +603,19 @@ impl Config {
576603 let animation = section("animation", table.remove("animation"), w);
577604 let xwayland = section("xwayland", table.remove("xwayland"), w);
578605 let decoration = section("decoration", table.remove("decoration"), w);
579- let output: Outputs = section("output", table.remove("output"), w);
580- for name in output.named.keys().filter(|n| !connector_like(n)) {
606+ let mut output: Outputs = section("output", table.remove("output"), w);
607+ for name in output
608+ .named
609+ .keys()
610+ .filter(|n| *n != "nested" && !connector_like(n))
611+ {
581612 w.push(format!(
582613 "output.{name}: not a connector name like DP-1 or HDMI-A-1, matches nothing"
583614 ));
584615 }
616+ for (name, config) in &mut output.named {
617+ config.validate(&format!("output.{name}"), w);
618+ }
585619 let startup = items("startup", table.remove("startup"), w);
586620 let mut binds = Vec::new();
587621 if let Some(value) = table.remove("binds") {
@@ -876,6 +910,28 @@ mod tests {
876910 }
877911
878912 #[test]
913+ fn output_scale_is_snapped_and_range_checked() {
914+ let loaded = Config::parse(
915+ r#"
916+ [output.DP-1]
917+ scale = 1.5
918+ [output.DP-2]
919+ scale = 1.3333
920+ [output.DP-3]
921+ scale = 9.0
922+ [output.nested]
923+ scale = 2.0
924+ "#,
925+ )
926+ .unwrap();
927+ let named = &loaded.config.output.named;
928+ assert_eq!(named["DP-1"].scale(), 1.5);
929+ assert_eq!(named["DP-2"].scale(), 160.0 / 120.0);
930+ assert_eq!(named["DP-3"].scale(), 1.0);
931+ assert_eq!(loaded.warnings.len(), 1, "{:#?}", loaded.warnings);
932+ }
933+
934+ #[test]
879935 fn a_bad_option_costs_only_itself() {
880936 let loaded = Config::parse(
881937 r##"