|
1 |
+ |
//! `alloy display` — the outputs sway is driving, and the two places a change
|
|
2 |
+ |
//! has to land to be worth making.
|
|
3 |
+ |
//!
|
|
4 |
+ |
//! Fronts `swaymsg` and nothing else. Alloy ships sway as *the* compositor, so a
|
|
5 |
+ |
//! generic wlroots tool (`wlr-randr`) would buy portability the distro cannot
|
|
6 |
+ |
//! spend and cost a package plus a second output format to parse. Neither
|
|
7 |
+ |
//! `wlr-randr` nor `kanshi` is in the image, and this verb adds no packages.
|
|
8 |
+ |
//!
|
|
9 |
+ |
//! # There is only ever one string
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! sway's config directive and swaymsg's runtime command are the same words:
|
|
12 |
+ |
//!
|
|
13 |
+ |
//! ```text
|
|
14 |
+ |
//! output eDP-1 scale 1.25 # in a config file: survives reboot
|
|
15 |
+ |
//! swaymsg output eDP-1 scale 1.25 # at runtime: applies now
|
|
16 |
+ |
//! ```
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! So [`Directive`] builds those words once. [`Directive::invocation`] runs them
|
|
19 |
+ |
//! and [`Directive::line`] writes the identical text to
|
|
20 |
+ |
//! `~/.config/sway/config.d/50-display.conf` for the next boot. No translation
|
|
21 |
+ |
//! layer, no second syntax, nothing to keep in agreement, and the line the log
|
|
22 |
+ |
//! pane shows is a line the user can paste into their own sway config.
|
|
23 |
+ |
//!
|
|
24 |
+ |
//! ## Which is why the quotes are ours to write
|
|
25 |
+ |
//!
|
|
26 |
+ |
//! swaymsg does not pass argv through: its `main` ends at
|
|
27 |
+ |
//! `join_args(argv + optind, argc - optind)`, and sway's `join_args` is a plain
|
|
28 |
+ |
//! space join with no quoting of its own (read in sway 1.11's `swaymsg/main.c`
|
|
29 |
+ |
//! and `common/stringop.c`). The joined string is then split by the same parser
|
|
30 |
+ |
//! that reads a config file. So an identifier containing spaces —
|
|
31 |
+ |
//! `BOE NV122WUM-N42 Unknown` is one — has to carry its quotes *in the string*,
|
|
32 |
+ |
//! or sway sees three words where one was meant. Both consumers need the same
|
|
33 |
+ |
//! quoting for the same reason, which is the one-string claim holding rather
|
|
34 |
+ |
//! than an exception to it. See [`quote`].
|
|
35 |
+ |
//!
|
|
36 |
+ |
//! # sway is the persistence layer; there is no kanshi
|
|
37 |
+ |
//!
|
|
38 |
+ |
//! sway re-applies stored `output` config when an output appears, matching by
|
|
39 |
+ |
//! connector name or by the `make model serial` identifier. That is kanshi's
|
|
40 |
+ |
//! core feature, built in. Verified in sway 1.11's source rather than assumed:
|
|
41 |
+ |
//! `handle_new_output` (`sway/desktop/output.c`) ends in `request_modeset`,
|
|
42 |
+ |
//! which reaches `apply_stored_output_configs`, which merges, in order, the `*`
|
|
43 |
+ |
//! wildcard, the connector name, and then the identifier. What kanshi adds on
|
|
44 |
+ |
//! top is group profiles — "when these three monitors are present, arrange them
|
|
45 |
+ |
//! thus" — which is a docking feature, and there is no dock.
|
|
46 |
+ |
//!
|
|
47 |
+ |
//! **Because those matches merge rather than replace, the file is regenerated
|
|
48 |
+ |
//! whole.** Appending a second stanza for an output leaves the first one's
|
|
49 |
+ |
//! properties live, so a scale the user moved away from would keep applying at
|
|
50 |
+ |
//! the next boot. [`config_file`] writes one stanza per output, every time.
|
|
51 |
+ |
//!
|
|
52 |
+ |
//! # Where the file goes, and why not `/etc`
|
|
53 |
+ |
//!
|
|
54 |
+ |
//! `~/.config/sway/config.d/50-display.conf`, owned and regenerated by the
|
|
55 |
+ |
//! console. Per-user and unprivileged: `system.rs`'s theme row already argues
|
|
56 |
+ |
//! that what a session renders in is the person's business and not the
|
|
57 |
+ |
//! machine's, and display scale is the same class of preference. The shipped
|
|
58 |
+ |
//! sway config includes `~/.config/sway/config.d/*` after
|
|
59 |
+ |
//! `/etc/sway/config.d/*`, so the user's file wins on a conflicting property.
|
|
60 |
+ |
//!
|
|
61 |
+ |
//! The path is built from `$HOME` and not from `$XDG_CONFIG_HOME`, which is the
|
|
62 |
+ |
//! one place this deliberately disagrees with the rest of the console. The
|
|
63 |
+ |
//! include line in the shipped config names `~/.config/sway/config.d/*`
|
|
64 |
+ |
//! literally, so honoring a custom `XDG_CONFIG_HOME` here would write a
|
|
65 |
+ |
//! well-formed file that nothing reads.
|
|
66 |
+ |
//!
|
|
67 |
+ |
//! # The one edit that costs the session
|
|
68 |
+ |
//!
|
|
69 |
+ |
//! Everything else this verb does is recoverable by pressing the key again.
|
|
70 |
+ |
//! Disabling or blanking the only active output is not: the screen goes dark
|
|
71 |
+ |
//! with no way back, and in a per-user config file it survives the reboot that
|
|
72 |
+ |
//! would otherwise recover it. [`refuse`] is the gate, and it runs before the
|
|
73 |
+ |
//! runtime apply as well as before the write.
|
|
74 |
+ |
//!
|
|
75 |
+ |
//! # What is not here
|
|
76 |
+ |
//!
|
|
77 |
+ |
//! No mode picker. The only machine that can test this advertises exactly one
|
|
78 |
+ |
//! mode, and no multi-output capture exists yet, so a mode list would be a UI
|
|
79 |
+ |
//! written against nothing. `Output::modes` is parsed and shown so the gap is
|
|
80 |
+ |
//! visible; choosing between them waits for hardware that has a choice.
|
|
81 |
+ |
//!
|
|
82 |
+ |
//! <!-- wiki: alloy-console -->
|
|
83 |
+ |
|
|
84 |
+ |
// Writing into a String cannot fail, so the `let _ =` at each call site is
|
|
85 |
+ |
// discarding an error that does not exist rather than ignoring a real one. Same
|
|
86 |
+ |
// use as `pkg.rs`'s.
|
|
87 |
+ |
use std::fmt::Write as _;
|
|
88 |
+ |
use std::path::PathBuf;
|
|
89 |
+ |
|
|
90 |
+ |
use alloy_tui::keys::Action;
|
|
91 |
+ |
use alloy_tui::{
|
|
92 |
+ |
AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, Theme, binding, hint, text,
|
|
93 |
+ |
unavailable,
|
|
94 |
+ |
};
|
|
95 |
+ |
use anyhow::Result;
|
|
96 |
+ |
use ratatui::Frame;
|
|
97 |
+ |
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
|
98 |
+ |
use ratatui::layout::Rect;
|
|
99 |
+ |
use ratatui::text::{Line, Span};
|
|
100 |
+ |
use serde::Deserialize;
|
|
101 |
+ |
|
|
102 |
+ |
use crate::cli::{CommandLog, Effect, Invocation};
|
|
103 |
+ |
use crate::shell::{Flow, View, block_title};
|
|
104 |
+ |
|
|
105 |
+ |
/// The file this verb owns.
|
|
106 |
+ |
const FILE: &str = ".config/sway/config.d/50-display.conf";
|
|
107 |
+ |
|
|
108 |
+ |
/// What sway reports for a field the panel does not carry.
|
|
109 |
+ |
///
|
|
110 |
+ |
/// The literal string, not null and not absent: `output_get_identifier` in
|
|
111 |
+ |
/// sway's `config/output.c` substitutes it for a missing make, model, or serial.
|
|
112 |
+ |
/// The FW12 panel's serial is exactly this, which is why the identifier rule
|
|
113 |
+ |
/// below does not trust a triple to be unique.
|
|
114 |
+ |
const UNKNOWN: &str = "Unknown";
|
|
115 |
+ |
|
|
116 |
+ |
/// The scales the `s` key walks.
|
|
117 |
+ |
///
|
|
118 |
+ |
/// A ladder rather than a text field: the useful values on a HiDPI panel are
|
|
119 |
+ |
/// quarter steps, and a field would need its own validation to keep somebody
|
|
120 |
+ |
/// from typing a scale that leaves the session unreadable. Anything off the
|
|
121 |
+ |
/// ladder still displays correctly, and the key moves to the next rung above it.
|
|
122 |
+ |
const SCALES: [f64; 5] = [1.0, 1.25, 1.5, 1.75, 2.0];
|
|
123 |
+ |
|
|
124 |
+ |
/// One mode an output advertises.
|
|
125 |
+ |
///
|
|
126 |
+ |
/// `refresh` is millihertz. sway reports `60002` for a 60 Hz panel, so dividing
|
|
127 |
+ |
/// by 1000 in the wrong place is how a mode line comes out as `@60Hz` for a
|
|
128 |
+ |
/// panel that does not have one.
|
|
129 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
130 |
+ |
pub(crate) struct Mode {
|
|
131 |
+ |
pub width: u32,
|
|
132 |
+ |
pub height: u32,
|
|
133 |
+ |
/// Millihertz, as sway reports it.
|
|
134 |
+ |
#[serde(default)]
|
|
135 |
+ |
pub refresh: u32,
|
|
136 |
+ |
}
|
|
137 |
+ |
|
|
138 |
+ |
impl Mode {
|
|
139 |
+ |
/// The mode as sway's `mode` directive spells it.
|
|
140 |
+ |
///
|
|
141 |
+ |
/// Three decimals because that is what millihertz carries and because
|
|
142 |
+ |
/// `60.002` is a real refresh rate: rounding it to `60` names a mode the
|
|
143 |
+ |
/// panel does not advertise, and sway matches modes by value.
|
|
144 |
+ |
fn spelled(self) -> String {
|
|
145 |
+ |
format!(
|
|
146 |
+ |
"{}x{}@{:.3}Hz",
|
|
147 |
+ |
self.width,
|
|
148 |
+ |
self.height,
|
|
149 |
+ |
f64::from(self.refresh) / 1000.0
|
|
150 |
+ |
)
|
|
151 |
+ |
}
|
|
152 |
+ |
}
|
|
153 |
+ |
|
|
154 |
+ |
/// A rectangle in sway's layout, in logical pixels.
|
|
155 |
+ |
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
|
156 |
+ |
pub(crate) struct Rectangle {
|
|
157 |
+ |
#[serde(default)]
|
|
158 |
+ |
pub x: i32,
|
|
159 |
+ |
#[serde(default)]
|
|
160 |
+ |
pub y: i32,
|
|
161 |
+ |
#[serde(default)]
|
|
162 |
+ |
pub width: u32,
|
|
163 |
+ |
#[serde(default)]
|
|
164 |
+ |
pub height: u32,
|
|
165 |
+ |
}
|
|
166 |
+ |
|
|
167 |
+ |
/// One output, as `swaymsg -t get_outputs` describes it.
|
|
168 |
+ |
///
|
|
169 |
+ |
/// Every field here is one the view or a directive reads. sway sends a good deal
|
|
170 |
+ |
/// more (`percent`, `deco_rect`, `nodes`, the null `floating` and
|
|
171 |
+ |
/// `scratchpad_state`), and serde ignores what is not named — which is the
|
|
172 |
+ |
/// reason this is a derive rather than a hand-rolled reader. sway escapes
|
|
173 |
+ |
/// forward slashes in its JSON strings, and a string reader written for this
|
|
174 |
+ |
/// payload is a string reader that has to know that.
|
|
175 |
+ |
#[derive(Debug, Clone, Deserialize)]
|
|
176 |
+ |
pub(crate) struct Output {
|
|
177 |
+ |
/// The connector name: `eDP-1`, `DP-3`, `HDMI-A-1`.
|
|
178 |
+ |
pub name: String,
|
|
179 |
+ |
#[serde(default)]
|
|
180 |
+ |
pub make: String,
|
|
181 |
+ |
#[serde(default)]
|
|
182 |
+ |
pub model: String,
|
|
183 |
+ |
#[serde(default)]
|
|
184 |
+ |
pub serial: String,
|
|
185 |
+ |
/// Whether sway is driving it. An output that is present but disabled is
|
|
186 |
+ |
/// listed with `active: false`, and it is still ours to re-enable.
|
|
187 |
+ |
#[serde(default)]
|
|
188 |
+ |
pub active: bool,
|
|
189 |
+ |
/// Whether the panel is awake. Distinct from `active`: a `dpms off` output
|
|
190 |
+ |
/// is still in the layout, just dark.
|
|
191 |
+ |
#[serde(default = "yes")]
|
|
192 |
+ |
pub dpms: bool,
|
|
193 |
+ |
#[serde(default)]
|
|
194 |
+ |
pub focused: bool,
|
|
195 |
+ |
/// Logical size and position, which at any scale other than 1 disagrees with
|
|
196 |
+ |
/// `current_mode` by design.
|
|
197 |
+ |
#[serde(default)]
|
|
198 |
+ |
pub rect: Rectangle,
|
|
199 |
+ |
#[serde(default = "one")]
|
|
200 |
+ |
pub scale: f64,
|
|
201 |
+ |
#[serde(default)]
|
|
202 |
+ |
pub transform: String,
|
|
203 |
+ |
#[serde(default)]
|
|
204 |
+ |
pub current_mode: Option<Mode>,
|
|
205 |
+ |
#[serde(default)]
|
|
206 |
+ |
pub modes: Vec<Mode>,
|
|
207 |
+ |
}
|
|
208 |
+ |
|
|
209 |
+ |
/// `dpms` defaults to true when absent: an output sway lists and does not
|
|
210 |
+ |
/// describe as asleep is awake. Guessing the other way would draw every row as
|
|
211 |
+ |
/// blanked on a sway version that stopped sending the field.
|
|
212 |
+ |
fn yes() -> bool {
|
|
213 |
+ |
true
|
|
214 |
+ |
}
|
|
215 |
+ |
|
|
216 |
+ |
fn one() -> f64 {
|
|
217 |
+ |
1.0
|
|
218 |
+ |
}
|
|
219 |
+ |
|
|
220 |
+ |
impl Output {
|
|
221 |
+ |
/// Whether this is the machine's built-in panel.
|
|
222 |
+ |
///
|
|
223 |
+ |
/// By connector prefix, which is the only signal in the payload: eDP is the
|
|
224 |
+ |
/// embedded DisplayPort every laptop panel arrives on, LVDS is what older
|
|
225 |
+ |
/// ones used, and DSI is the small-board and tablet case. sway derives these
|
|
226 |
+ |
/// names from DRM, so they are the kernel's vocabulary rather than sway's.
|
|
227 |
+ |
pub(crate) fn built_in(&self) -> bool {
|
|
228 |
+ |
let name = self.name.to_ascii_uppercase();
|
|
229 |
+ |
["EDP", "LVDS", "DSI"]
|
|
230 |
+ |
.iter()
|
|
231 |
+ |
.any(|prefix| name.starts_with(prefix))
|
|
232 |
+ |
}
|
|
233 |
+ |
|
|
234 |
+ |
/// The name a config stanza should match this output by.
|
|
235 |
+ |
///
|
|
236 |
+ |
/// The `make model serial` triple for external outputs, because it survives
|
|
237 |
+ |
/// being replugged into a different port and the connector name does not.
|
|
238 |
+ |
/// The connector name for the built-in panel, because the triple buys
|
|
239 |
+ |
/// nothing there — a laptop panel does not move — and because a serial of
|
|
240 |
+ |
/// `Unknown` makes the triple ambiguous rather than portable: two identical
|
|
241 |
+ |
/// serial-less panels produce the same one.
|
|
242 |
+ |
///
|
|
243 |
+ |
/// An external output with no make and no model falls back to the connector
|
|
244 |
+ |
/// name for the same reason. `Unknown Unknown Unknown` matches every such
|
|
245 |
+ |
/// output at once, which is the one identifier that could apply a stanza to
|
|
246 |
+ |
/// hardware it was never written for.
|
|
247 |
+ |
pub(crate) fn identifier(&self) -> String {
|
|
248 |
+ |
if self.built_in() || !self.identifiable() {
|
|
249 |
+ |
return self.name.clone();
|
|
250 |
+ |
}
|
|
251 |
+ |
format!("{} {} {}", self.make, self.model, self.serial)
|
|
252 |
+ |
}
|
|
253 |
+ |
|
|
254 |
+ |
/// Whether the triple says anything specific about this output.
|
|
255 |
+ |
///
|
|
256 |
+ |
/// Make or model is enough. Serial alone is not: it is the field most often
|
|
257 |
+ |
/// `Unknown`, and a triple carrying only a serial is a triple that has
|
|
258 |
+ |
/// already lost the two fields that identify the panel.
|
|
259 |
+ |
fn identifiable(&self) -> bool {
|
|
260 |
+ |
[&self.make, &self.model]
|
|
261 |
+ |
.into_iter()
|
|
262 |
+ |
.any(|field| !field.is_empty() && field != UNKNOWN)
|
|
263 |
+ |
}
|
|
264 |
+ |
|
|
265 |
+ |
/// What the row shows for a make and model, when it has one.
|
|
266 |
+ |
fn description(&self) -> String {
|
|
267 |
+ |
let named: Vec<&str> = [self.make.as_str(), self.model.as_str()]
|
|
268 |
+ |
.into_iter()
|
|
269 |
+ |
.filter(|field| !field.is_empty() && *field != UNKNOWN)
|
|
270 |
+ |
.collect();
|
|
271 |
+ |
named.join(" ")
|
|
272 |
+ |
}
|
|
273 |
+ |
|
|
274 |
+ |
/// Whether this output is contributing a usable screen right now.
|
|
275 |
+ |
///
|
|
276 |
+ |
/// Both halves, because either one alone leaves the user looking at nothing:
|
|
277 |
+ |
/// an inactive output is out of the layout, and a `dpms off` output is in it
|
|
278 |
+ |
/// and dark. [`refuse`] counts these.
|
|
279 |
+ |
fn usable(&self) -> bool {
|
|
280 |
+ |
self.active && self.dpms
|
|
281 |
+ |
}
|
|
282 |
+ |
|
|
283 |
+ |
/// The next rung of the scale ladder above the current scale.
|
|
284 |
+ |
///
|
|
285 |
+ |
/// Wraps, and a scale that is off the ladder lands on the first rung above
|
|
286 |
+ |
/// it rather than snapping to the nearest, so pressing the key always
|
|
287 |
+ |
/// changes something. `1.0` is the wrap target rather than a hard floor,
|
|
288 |
+ |
/// which is what keeps the key from being a dead end at the top.
|
|
289 |
+ |
fn next_scale(&self) -> f64 {
|
|
290 |
+ |
SCALES
|
|
291 |
+ |
.iter()
|
|
292 |
+ |
.copied()
|
|
293 |
+ |
// Not `>` on floats parsed from JSON: sway sends 1.25 and the ladder
|
|
294 |
+ |
// holds 1.25, and an exact-equality assumption between the two is a
|
|
295 |
+ |
// key that stops working on a value that reads identical.
|
|
296 |
+ |
.find(|rung| *rung > self.scale + f64::EPSILON)
|
|
297 |
+ |
.unwrap_or(SCALES[0])
|
|
298 |
+ |
}
|
|
299 |
+ |
|
|
300 |
+ |
/// The properties the console persists for this output, in the order the
|
|
301 |
+ |
/// stanza spells them.
|
|
302 |
+ |
///
|
|
303 |
+ |
/// Deliberately short. Everything here is something the console can set and
|
|
304 |
+ |
/// the user can see, and everything omitted is either sway's default or a
|
|
305 |
+ |
/// decision this verb does not make yet: no `position` (that wants a layout
|
|
306 |
+ |
/// UI), no `mode` (the only testable panel advertises one), no `bg` (swww
|
|
307 |
+ |
/// owns the wallpaper).
|
|
308 |
+ |
fn persisted(&self) -> Vec<Directive> {
|
|
309 |
+ |
let mut directives = vec![Directive::new(self, "scale", spell_scale(self.scale))];
|
|
310 |
+ |
// `normal` is sway's default, so writing it says nothing and reads as
|
|
311 |
+ |
// though the console had an opinion about rotation.
|
|
312 |
+ |
if !self.transform.is_empty() && self.transform != "normal" {
|
|
313 |
+ |
directives.push(Directive::new(self, "transform", &self.transform));
|
|
314 |
+ |
}
|
|
315 |
+ |
// An output the user turned off stays off across a reboot, which is the
|
|
316 |
+ |
// whole point of persisting it. `refuse` is what keeps this from being
|
|
317 |
+ |
// the last output standing.
|
|
318 |
+ |
if !self.active {
|
|
319 |
+ |
directives.push(Directive::new(self, "enable", "false"));
|
|
320 |
+ |
}
|
|
321 |
+ |
directives
|
|
322 |
+ |
}
|
|
323 |
+ |
}
|
|
324 |
+ |
|
|
325 |
+ |
/// Spell a scale the way sway's parser reads it back.
|
|
326 |
+ |
///
|
|
327 |
+ |
/// Trailing zeros trimmed, because `scale 1.250000` is the same instruction
|
|
328 |
+ |
/// spelled to look machine-generated, and the whole point of the shared string
|
|
329 |
+ |
/// is that a person can read the line and paste it.
|
|
330 |
+ |
fn spell_scale(scale: f64) -> String {
|
|
331 |
+ |
let spelled = format!("{scale:.3}");
|
|
332 |
+ |
let trimmed = spelled.trim_end_matches('0').trim_end_matches('.');
|
|
333 |
+ |
if trimmed.is_empty() {
|
|
334 |
+ |
"1".to_string()
|
|
335 |
+ |
} else {
|
|
336 |
+ |
trimmed.to_string()
|
|
337 |
+ |
}
|
|
338 |
+ |
}
|
|
339 |
+ |
|
|
340 |
+ |
/// One `output <identifier> <property> <value>` instruction.
|
|
341 |
+ |
///
|
|
342 |
+ |
/// The words after the command name, which is the whole of what sway needs and
|
|
343 |
+ |
/// exactly what a config file holds. Held as its three parts rather than a
|
|
344 |
+ |
/// finished string so the identifier can be quoted once, in [`words`], for both
|
|
345 |
+ |
/// consumers.
|
|
346 |
+ |
///
|
|
347 |
+ |
/// [`words`]: Directive::words
|
|
348 |
+ |
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
349 |
+ |
pub(crate) struct Directive {
|
|
350 |
+ |
identifier: String,
|
|
351 |
+ |
property: &'static str,
|
|
352 |
+ |
value: String,
|
|
353 |
+ |
}
|
|
354 |
+ |
|
|
355 |
+ |
impl Directive {
|
|
356 |
+ |
fn new(output: &Output, property: &'static str, value: impl Into<String>) -> Self {
|
|
357 |
+ |
Self {
|
|
358 |
+ |
identifier: output.identifier(),
|
|
359 |
+ |
property,
|
|
360 |
+ |
value: value.into(),
|
|
361 |
+ |
}
|
|
362 |
+ |
}
|
|
363 |
+ |
|
|
364 |
+ |
/// The instruction, quoted for sway's parser.
|
|
365 |
+ |
pub(crate) fn words(&self) -> String {
|
|
366 |
+ |
format!(
|
|
367 |
+ |
"output {} {} {}",
|
|
368 |
+ |
quote(&self.identifier),
|
|
369 |
+ |
self.property,
|
|
370 |
+ |
self.value
|
|
371 |
+ |
)
|
|
372 |
+ |
}
|
|
373 |
+ |
|
|
374 |
+ |
/// Apply it now.
|
|
375 |
+ |
///
|
|
376 |
+ |
/// One argv element per word, which is what makes the displayed line the one
|
|
377 |
+ |
/// a person would have typed: `swaymsg output eDP-1 scale 1.25`. Passing the
|
|
378 |
+ |
/// whole instruction as a single argument reaches sway identically, since
|
|
379 |
+ |
/// swaymsg joins its arguments with spaces anyway, but the log pane shell-
|
|
380 |
+ |
/// quotes an argument containing spaces and the line comes out as
|
|
381 |
+ |
/// `swaymsg 'output eDP-1 scale 1.25'`.
|
|
382 |
+ |
///
|
|
383 |
+ |
/// There are two layers of quoting here and each strips its own. The double
|
|
384 |
+ |
/// quotes around a triple identifier are sway's, and they have to survive the
|
|
385 |
+ |
/// shell; the single quotes the log adds are the shell's, and they do not
|
|
386 |
+ |
/// reach sway. A pasted `swaymsg output '"Example Co PA279CV S1"' scale 2` is
|
|
387 |
+ |
/// therefore the same instruction, which is the property the pane advertises.
|
|
388 |
+ |
pub(crate) fn invocation(&self) -> Invocation {
|
|
389 |
+ |
Invocation::new("swaymsg").args([
|
|
390 |
+ |
"output",
|
|
391 |
+ |
"e(&self.identifier),
|
|
392 |
+ |
self.property,
|
|
393 |
+ |
&self.value,
|
|
394 |
+ |
])
|
|
395 |
+ |
}
|
|
396 |
+ |
|
|
397 |
+ |
/// The line that puts it back at the next login. The same words.
|
|
398 |
+ |
pub(crate) fn line(&self) -> String {
|
|
399 |
+ |
self.words()
|
|
400 |
+ |
}
|
|
401 |
+ |
}
|
|
402 |
+ |
|
|
403 |
+ |
/// Quote an identifier if sway's parser would otherwise split it.
|
|
404 |
+ |
///
|
|
405 |
+ |
/// The `make model serial` triple is three words, and sway's config parser
|
|
406 |
+ |
/// splits on whitespace before it looks for an output. A connector name never
|
|
407 |
+ |
/// needs this, and quoting it anyway would make the log pane's line noisier than
|
|
408 |
+ |
/// the one a person would have typed.
|
|
409 |
+ |
fn quote(identifier: &str) -> String {
|
|
410 |
+ |
if identifier.contains(char::is_whitespace) {
|
|
411 |
+ |
format!("\"{identifier}\"")
|
|
412 |
+ |
} else {
|
|
413 |
+ |
identifier.to_string()
|
|
414 |
+ |
}
|
|
415 |
+ |
}
|
|
416 |
+ |
|
|
417 |
+ |
/// Why a change must not be made, or `None` when it may be.
|
|
418 |
+ |
///
|
|
419 |
+ |
/// One rule, stated once, checked before the runtime apply and before the write:
|
|
420 |
+ |
/// nothing may take away the last usable screen. `enable false` and `dpms off`
|
|
421 |
+ |
/// are the two direct ways, and a scale that leaves no readable geometry is the
|
|
422 |
+ |
/// indirect one — a session at scale 8 on a 1920x1200 panel is 240x150 logical
|
|
423 |
+ |
/// pixels, which is a dark screen with extra steps.
|
|
424 |
+ |
///
|
|
425 |
+ |
/// Returns the reason rather than a bool so the view can say which rule it hit.
|
|
426 |
+ |
/// "Nothing happened" is the one outcome a console must never produce.
|
|
427 |
+ |
fn refuse(outputs: &[Output], target: &Output, change: &Change) -> Option<String> {
|
|
428 |
+ |
let others_usable = outputs
|
|
429 |
+ |
.iter()
|
|
430 |
+ |
.filter(|other| other.name != target.name)
|
|
431 |
+ |
.any(Output::usable);
|
|
432 |
+ |
|
|
433 |
+ |
match change {
|
|
434 |
+ |
Change::Enabled(false) => {
|
|
435 |
+ |
(!others_usable).then(|| format!("{} is the only screen left", target.name))
|
|
436 |
+ |
}
|
|
437 |
+ |
Change::Scale(scale) => {
|
|
438 |
+ |
let mode = target
|
|
439 |
+ |
.current_mode
|
|
440 |
+ |
.or_else(|| target.modes.first().copied());
|
|
441 |
+ |
// No mode at all means no geometry to check. Refusing on that would
|
|
442 |
+ |
// block the key on every output sway has not finished describing,
|
|
443 |
+ |
// which is a worse failure than a scale that has to be pressed twice.
|
|
444 |
+ |
let mode = mode?;
|
|
445 |
+ |
let logical = f64::from(mode.width) / scale;
|
|
446 |
+ |
(logical < 640.0).then(|| {
|
|
447 |
+ |
format!(
|
|
448 |
+ |
"scale {} leaves {} at {} logical pixels wide",
|
|
449 |
+ |
spell_scale(*scale),
|
|
450 |
+ |
target.name,
|
|
451 |
+ |
logical.round(),
|
|
452 |
+ |
)
|
|
453 |
+ |
})
|
|
454 |
+ |
}
|
|
455 |
+ |
// Switching an output back on takes nothing away, and it is the key that
|
|
456 |
+ |
// recovers from the state the rules above are guarding against.
|
|
457 |
+ |
Change::Enabled(true) => None,
|
|
458 |
+ |
}
|
|
459 |
+ |
}
|
|
460 |
+ |
|
|
461 |
+ |
/// A change to one output.
|
|
462 |
+ |
///
|
|
463 |
+ |
/// Named for the property rather than the key, so the safety rule reads as a
|
|
464 |
+ |
/// rule about outputs instead of a rule about keystrokes.
|
|
465 |
+ |
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
466 |
+ |
pub(crate) enum Change {
|
|
467 |
+ |
Scale(f64),
|
|
468 |
+ |
Enabled(bool),
|
|
469 |
+ |
}
|
|
470 |
+ |
|
|
471 |
+ |
impl Change {
|
|
472 |
+ |
fn directive(self, output: &Output) -> Directive {
|
|
473 |
+ |
match self {
|
|
474 |
+ |
Change::Scale(scale) => Directive::new(output, "scale", spell_scale(scale)),
|
|
475 |
+ |
Change::Enabled(on) => {
|
|
476 |
+ |
Directive::new(output, "enable", if on { "true" } else { "false" })
|
|
477 |
+ |
}
|
|
478 |
+ |
}
|
|
479 |
+ |
}
|
|
480 |
+ |
}
|
|
481 |
+ |
|
|
482 |
+ |
/// Regenerate the whole file from the outputs sway is reporting.
|
|
483 |
+ |
///
|
|
484 |
+ |
/// Whole rather than patched, because sway merges every stanza that matches an
|
|
485 |
+ |
/// output: a leftover `scale 1.25` from a previous write keeps applying even
|
|
486 |
+ |
/// after a newer stanza sets something else. Regenerating is also what makes the
|
|
487 |
+ |
/// file honest — it describes the outputs as they are now, which is what the
|
|
488 |
+ |
/// user just looked at.
|
|
489 |
+ |
///
|
|
490 |
+ |
/// A hand edit is lost, and the header says so rather than leaving somebody to
|
|
491 |
+ |
/// find out. This is the one file in the skeleton the console claims outright;
|
|
492 |
+ |
/// `theme_apply.rs` keeps user edits because those files are seeds, and a seed
|
|
493 |
+ |
/// is not the same thing as a generated artifact.
|
|
494 |
+ |
pub(crate) fn config_file(outputs: &[Output]) -> String {
|
|
495 |
+ |
let mut out = String::from(
|
|
496 |
+ |
"# Generated by `alloy display`. Regenerated whole on every change, so\n\
|
|
497 |
+ |
# hand edits here are lost. Put your own output config in another file\n\
|
|
498 |
+ |
# in this directory: sway merges every stanza that matches an output,\n\
|
|
499 |
+ |
# and the shipped config includes all of ~/.config/sway/config.d/*.\n",
|
|
500 |
+ |
);
|