use crate::config::VpxConfig;
use crate::db::Database;
use crate::display_id::{self, DisplayAnchor, SdlDisplay};
use crate::screens::{DisplayInfo, DisplayRole};
const ROLE_KEYS: &[(DisplayRole, &str, &str)] = &[
(DisplayRole::Playfield, "Player", "PlayfieldDisplay"),
(DisplayRole::Backglass, "Backglass", "BackglassDisplay"),
(DisplayRole::Dmd, "ScoreView", "ScoreViewDisplay"),
(DisplayRole::Topper, "Topper", "TopperDisplay"),
];
fn anchor_db_key(role: DisplayRole) -> String {
format!("display_anchor_{role:?}")
}
fn serialize_anchor(a: &DisplayAnchor) -> String {
format!(
"{},{},{},{},{}",
a.x,
a.y,
a.width,
a.height,
a.fingerprint.as_deref().unwrap_or("")
)
}
fn parse_anchor(s: &str) -> Option<DisplayAnchor> {
let mut it = s.splitn(5, ',');
let x = it.next()?.parse().ok()?;
let y = it.next()?.parse().ok()?;
let width = it.next()?.parse().ok()?;
let height = it.next()?.parse().ok()?;
let fp = it.next().unwrap_or("");
Some(DisplayAnchor {
x,
y,
width,
height,
fingerprint: (!fp.is_empty()).then(|| fp.to_string()),
})
}
fn load_anchor(db: &Database, role: DisplayRole) -> Option<DisplayAnchor> {
db.get_config(&anchor_db_key(role))
.as_deref()
.and_then(parse_anchor)
}
fn size_db_key(name: &str) -> String {
format!("display_size_mm:{name}")
}
pub fn persist_sizes(db: &Database, displays: &[DisplayInfo]) {
for d in displays {
let key = size_db_key(&d.name);
let overridden = d.width_mm != d.detected_width_mm || d.height_mm != d.detected_height_mm;
let value = if overridden && d.width_mm > 0 && d.height_mm > 0 {
format!("{}x{}", d.width_mm, d.height_mm)
} else {
String::new()
};
if let Err(e) = db.set_config(&key, &value) {
log::warn!("could not persist physical size for {}: {e}", d.name);
}
}
}
pub fn apply_saved_sizes(db: &Database, displays: &mut [DisplayInfo]) {
for d in displays.iter_mut() {
let Some(raw) = db.get_config(&size_db_key(&d.name)) else {
continue;
};
let Some((w, h)) = parse_size_mm(&raw) else {
continue;
};
log::info!(
"display {}: using corrected size {w}x{h} mm (detection said {}x{})",
d.name,
d.detected_width_mm,
d.detected_height_mm
);
d.width_mm = w;
d.height_mm = h;
d.size_inches = diagonal_inches(w, h);
}
}
pub fn apply_saved_roles(config: &VpxConfig, db: &Database, displays: &mut [DisplayInfo]) {
let mut found = Vec::new();
for (role, section, key) in assigned_roles(config) {
let name = config.get(section, key).unwrap_or_default();
let idx = displays
.iter()
.position(|d| crate::vpx_display::matches(&name, &d.name, d.x, d.y))
.or_else(|| anchored_display_index(db, role, displays));
let Some(idx) = idx else {
log::info!("saved {role:?} display {name:?} not found, keeping guessed roles");
return;
};
found.push((role, idx));
}
restore_roles(displays, &found);
}
fn restore_roles(displays: &mut [DisplayInfo], found: &[(DisplayRole, usize)]) {
if found.is_empty() {
return;
}
for d in displays.iter_mut() {
d.role = DisplayRole::Unused;
}
for &(role, idx) in found {
displays[idx].role = role;
log::info!("display {}: saved role {role:?}", displays[idx].name);
}
}
fn parse_size_mm(raw: &str) -> Option<(i32, i32)> {
let (w, h) = raw.trim().split_once('x')?;
let (w, h) = (w.trim().parse().ok()?, h.trim().parse().ok()?);
(w > 0 && h > 0).then_some((w, h))
}
fn diagonal_inches(w: i32, h: i32) -> Option<u32> {
(w > 0 && h > 0).then(|| {
let diag = ((f64::from(w)).powi(2) + (f64::from(h)).powi(2)).sqrt();
(diag / 25.4).round() as u32
})
}
pub fn persist_anchors(db: &Database, displays: &[DisplayInfo]) {
let sdl: Vec<SdlDisplay> = displays.iter().map(to_sdl).collect();
let correlation = display_id::correlate(&sdl, &display_id::read_drm_monitors());
for &(role, _, _) in ROLE_KEYS {
let Some(d) = displays.iter().find(|d| d.role == role) else {
let _ = db.set_config(&anchor_db_key(role), "");
continue;
};
let fingerprint = correlation
.iter()
.find(|(name, _)| *name == d.name)
.map(|(_, id)| id.fingerprint.clone());
let anchor = DisplayAnchor {
x: d.x,
y: d.y,
width: d.width,
height: d.height,
fingerprint,
};
if let Err(e) = db.set_config(&anchor_db_key(role), &serialize_anchor(&anchor)) {
log::warn!("could not persist display anchor for {role:?}: {e}");
}
}
}
fn to_sdl(d: &DisplayInfo) -> SdlDisplay {
SdlDisplay {
name: d.name.clone(),
x: d.x,
y: d.y,
width: d.width,
height: d.height,
width_mm: d.width_mm,
height_mm: d.height_mm,
}
}
pub fn anchored_display_index(
db: &Database,
role: DisplayRole,
displays: &[DisplayInfo],
) -> Option<usize> {
let a = load_anchor(db, role)?;
displays
.iter()
.position(|d| d.x == a.x && d.y == a.y && d.width == a.width && d.height == a.height)
}
pub fn enumerate_via_subprocess(driver: &str) -> Vec<SdlDisplay> {
let Ok(exe) = std::env::current_exe() else {
return Vec::new();
};
let out = match std::process::Command::new(exe)
.arg("--enumerate-displays")
.arg(driver)
.output()
{
Ok(o) if o.status.success() => o.stdout,
Ok(o) => {
log::warn!(
"--enumerate-displays {driver} exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
);
return Vec::new();
}
Err(e) => {
log::warn!("could not spawn --enumerate-displays {driver}: {e}");
return Vec::new();
}
};
String::from_utf8_lossy(&out)
.lines()
.filter_map(|line| {
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 8 {
return None;
}
Some(SdlDisplay {
name: f[0].to_string(),
x: f[1].parse().ok()?,
y: f[2].parse().ok()?,
width: f[3].parse().ok()?,
height: f[4].parse().ok()?,
width_mm: f[6].parse().ok()?,
height_mm: f[7].parse().ok()?,
})
})
.collect()
}
fn assigned_roles(config: &VpxConfig) -> Vec<(DisplayRole, &'static str, &'static str)> {
ROLE_KEYS
.iter()
.copied()
.filter(|&(_, section, key)| config.get(section, key).is_some_and(|v| !v.is_empty()))
.collect()
}
fn reconcile_all(config: &mut VpxConfig, db: &Database, driver: &str) -> bool {
let roles = assigned_roles(config);
if roles.is_empty() {
return false;
}
let sdl = enumerate_via_subprocess(driver);
if sdl.is_empty() {
return false; }
let correlation = display_id::correlate(&sdl, &display_id::read_drm_monitors());
let ids = crate::vpx_display::uses_ids(config, db);
let mut resolved = Vec::new();
for (role, section, key) in roles {
let Some(anchor) = load_anchor(db, role) else {
return false; };
let Some(name) = display_id::resolve_anchor(&anchor, &sdl, &correlation) else {
return false; };
let pos = sdl
.iter()
.find(|d| d.name == name && d.x == anchor.x && d.y == anchor.y)
.or_else(|| sdl.iter().find(|d| d.name == name))
.map(|d| (d.x, d.y));
let Some((x, y)) = pos else {
return false;
};
resolved.push((section, key, crate::vpx_display::value(&name, x, y, ids)));
}
for (section, key, name) in resolved {
config.set(section, key, &name);
}
true
}
pub fn unresolvable_assigned_displays(
config: &VpxConfig,
db: &Database,
connected: &[DisplayInfo],
) -> Vec<String> {
let sdl: Vec<SdlDisplay> = connected.iter().map(to_sdl).collect();
let correlation = display_id::correlate(&sdl, &display_id::read_drm_monitors());
let mut unresolved = Vec::new();
for &(role, section, key) in ROLE_KEYS {
let Some(name) = config.get(section, key).filter(|v| !v.is_empty()) else {
continue; };
let ok = match load_anchor(db, role) {
Some(anchor) => display_id::resolve_anchor(&anchor, &sdl, &correlation).is_some(),
None => connected
.iter()
.any(|d| crate::vpx_display::matches(&name, &d.name, d.x, d.y)),
};
if !ok {
unresolved.push(name);
}
}
unresolved
}
fn secondary_fullscreen(session: Option<&str>) -> i32 {
if session == Some("x11") { 0 } else { 1 }
}
fn reconcile_fullscreen(config: &mut VpxConfig, session: Option<&str>) {
let fs = secondary_fullscreen(session);
for (disp_section, disp_key, fs_key) in [
("Backglass", "BackglassDisplay", "BackglassFullScreen"),
("ScoreView", "ScoreViewDisplay", "ScoreViewFullScreen"),
("Topper", "TopperDisplay", "TopperFullScreen"),
] {
let assigned = config
.get(disp_section, disp_key)
.is_some_and(|v| !v.is_empty());
if assigned {
config.set_i32(disp_section, fs_key, fs);
}
}
}
pub fn choose_driver_and_reconcile(config: &mut VpxConfig, db: &Database) -> Option<&'static str> {
let session = crate::session::detect();
reconcile_fullscreen(config, session);
if let Some(preferred) = crate::wayland_caps::preferred_vpx_driver(session) {
if reconcile_all(config, db, preferred) {
log::info!("VPX display driver: {preferred} (screens reconciled)");
return Some(preferred);
}
log::info!(
"VPX display driver: falling back to session driver {:?} \
(preferred {preferred} could not reconcile all screens)",
session
);
}
session
}
#[cfg(test)]
mod tests {
#[test]
fn only_a_corrected_size_is_remembered() {
assert_eq!(super::parse_size_mm("932x540"), Some((932, 540)));
assert_eq!(super::parse_size_mm(" 932 x 540 "), Some((932, 540)));
assert_eq!(super::parse_size_mm(""), None);
assert_eq!(super::parse_size_mm("0x0"), None);
assert_eq!(super::parse_size_mm("932"), None);
assert_eq!(super::parse_size_mm("nonsense"), None);
}
#[test]
fn correcting_the_millimetres_corrects_the_inches() {
assert_eq!(super::diagonal_inches(1600, 900), Some(72));
assert_eq!(super::diagonal_inches(932, 540), Some(42));
assert_eq!(super::diagonal_inches(0, 0), None);
}
use super::*;
fn display(name: &str, x: i32, w: i32, h: i32) -> DisplayInfo {
DisplayInfo {
name: name.to_string(),
x,
y: 0,
width: w,
height: h,
refresh_rate: 60.0,
is_primary: false,
total_pixels: w as u64 * h as u64,
size_inches: None,
width_mm: 0,
height_mm: 0,
detected_width_mm: 0,
detected_height_mm: 0,
role: DisplayRole::Unused,
monitor_index: 0,
content_scale: 1.0,
native_handle: None,
}
}
#[test]
fn saved_roles_beat_the_pixel_count_guess() {
let mut d = vec![
display("LG TV SSCR2", 0, 3840, 2160),
display("Monitor", 5760, 1920, 1080),
display("DELL S2721QS", 3840, 1920, 1080),
];
d[0].role = DisplayRole::Playfield;
d[1].role = DisplayRole::Backglass;
d[2].role = DisplayRole::Dmd;
restore_roles(
&mut d,
&[
(DisplayRole::Playfield, 0),
(DisplayRole::Backglass, 2),
(DisplayRole::Dmd, 1),
],
);
assert_eq!(d[0].role, DisplayRole::Playfield);
assert_eq!(d[1].role, DisplayRole::Dmd);
assert_eq!(d[2].role, DisplayRole::Backglass);
}
#[test]
fn unsaved_screens_become_unused() {
let mut d = vec![display("A", 0, 3840, 2160), display("B", 3840, 1920, 1080)];
d[1].role = DisplayRole::Backglass;
restore_roles(&mut d, &[(DisplayRole::Playfield, 0)]);
assert_eq!(d[0].role, DisplayRole::Playfield);
assert_eq!(d[1].role, DisplayRole::Unused);
}
#[test]
fn anchor_roundtrip_with_fingerprint() {
let a = DisplayAnchor {
x: 0,
y: 0,
width: 3840,
height: 2160,
fingerprint: Some("deadbeef".into()),
};
let parsed = parse_anchor(&serialize_anchor(&a)).unwrap();
assert_eq!(parsed.x, 0);
assert_eq!(parsed.width, 3840);
assert_eq!(parsed.fingerprint.as_deref(), Some("deadbeef"));
}
#[test]
fn anchor_roundtrip_without_fingerprint() {
let a = DisplayAnchor {
x: 3840,
y: 0,
width: 1920,
height: 1080,
fingerprint: None,
};
let parsed = parse_anchor(&serialize_anchor(&a)).unwrap();
assert_eq!(parsed.x, 3840);
assert_eq!(parsed.fingerprint, None);
}
#[test]
fn parse_anchor_rejects_short() {
assert!(parse_anchor("0,0,3840").is_none());
}
#[test]
fn secondary_windows_are_windowed_only_under_real_x11() {
assert_eq!(secondary_fullscreen(Some("x11")), 0);
assert_eq!(secondary_fullscreen(Some("wayland")), 1);
assert_eq!(secondary_fullscreen(None), 1);
}
}