Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
337 changes: 326 additions & 11 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ image = { version = "0.25", default-features = false, features = [
] }
qrcode = { version = "0.14", default-features = false }
rfd = { version = "0.15", default-features = false }
display-info = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/haru-apply/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ ureq = { workspace = true }

[target.'cfg(windows)'.dependencies]
uds_windows = { workspace = true }
# The monitor's own name for a screen kirie calls DISPLAY6.
display-info = { workspace = true }

[lints]
workspace = true
10 changes: 4 additions & 6 deletions crates/haru-apply/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;

use crate::launch::Plan;
use crate::{Backend, Screen, install, launch};
use crate::{Backend, Screen, install, launch, monitors};

const POLL: Duration = Duration::from_millis(900);

Expand Down Expand Up @@ -218,11 +218,12 @@ fn start(

fn poll(engine: &dyn Backend, shared: &Arc<Mutex<Snapshot>>) {
let available = engine.available();
let screens = if available {
let mut screens = if available {
engine.screens().unwrap_or_default()
} else {
Vec::new()
};
monitors::label(&mut screens);
let pid = launch::pid();
let binary = install::installed();
let connectors = if cfg!(target_os = "linux") || screens.is_empty() {
Expand Down Expand Up @@ -272,10 +273,7 @@ mod tests {
if cfg!(target_os = "linux") {
return;
}
let engine = Reporting(vec![Screen {
name: "Built-in Retina Display".to_owned(),
current: None,
}]);
let engine = Reporting(vec![Screen::new("Built-in Retina Display", None)]);
let shared = Arc::new(Mutex::new(Snapshot::default()));
poll(&engine, &shared);
let seen = shared.lock().map(|held| held.clone()).unwrap_or_default();
Expand Down
8 changes: 4 additions & 4 deletions crates/haru-apply/src/kirie.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ impl Backend for Kirie {
.filter_map(|line| {
let rest = line.strip_prefix("screen=")?;
let (name, background) = rest.split_once(" bg=")?;
Some(Screen {
name: name.to_owned(),
current: (!background.is_empty()).then(|| PathBuf::from(background)),
})
Some(Screen::new(
name,
(!background.is_empty()).then(|| PathBuf::from(background)),
))
})
.collect())
}
Expand Down
17 changes: 17 additions & 0 deletions crates/haru-apply/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod engine;
pub mod install;
mod kirie;
pub mod launch;
mod monitors;
mod offscreen;
mod relaunch;
mod socket;
Expand All @@ -27,10 +28,26 @@ pub use stream::{Frame, Preview as PreviewStream};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Screen {
/// What the renderer calls it, and what commands to it must use.
pub name: String,
/// What to show a person: the monitor's own name where the system has
/// one, since Windows' `DISPLAY6` says nothing about which screen it is.
pub label: String,
pub current: Option<PathBuf>,
}

impl Screen {
#[must_use]
pub fn new(name: impl Into<String>, current: Option<PathBuf>) -> Self {
let name = name.into();
Self {
label: name.clone(),
name,
current,
}
}
}

pub trait Backend: Send + Sync {
fn name(&self) -> &'static str;

Expand Down
177 changes: 177 additions & 0 deletions crates/haru-apply/src/monitors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! Names a person recognises for the screens the renderer reports.
//!
//! kirie names a Windows screen after its GDI device, `DISPLAY6`, which is
//! neither the number Windows' own settings show nor anything printed on the
//! monitor. Wayland and X11 connector names (`DP-1`) stay as they are.

use crate::Screen;

#[cfg(windows)]
pub(crate) fn label(screens: &mut [Screen]) {
use std::collections::HashMap;
use std::sync::Mutex;

/// The screens last asked about, and what they were called.
type Asked = (Vec<String>, HashMap<String, String>);

// Asked again only when the set of screens changes, not on every poll.
static KNOWN: Mutex<Option<Asked>> = Mutex::new(None);

let names: Vec<String> = screens.iter().map(|screen| screen.name.clone()).collect();
let Ok(mut known) = KNOWN.lock() else {
return;
};
let fresh = known
.as_ref()
.is_none_or(|(asked_for, _)| *asked_for != names);
if fresh {
let monitors = display_info::DisplayInfo::all()
.unwrap_or_default()
.into_iter()
.map(|display| Monitor {
device: device_tail(&display.name).to_owned(),
model: display.friendly_name,
builtin: display.is_builtin,
})
.collect::<Vec<_>>();
*known = Some((names.clone(), labels(&names, &monitors)));
}
if let Some((_, labels)) = known.as_ref() {
for screen in screens {
if let Some(label) = labels.get(&screen.name) {
screen.label.clone_from(label);
}
}
}
}

#[cfg(not(windows))]
pub(crate) fn label(_screens: &mut [Screen]) {}

#[cfg_attr(not(windows), allow(dead_code))]
struct Monitor {
/// `DISPLAY6`, from `\\.\DISPLAY6`, which is how kirie names it.
device: String,
/// The EDID name, `DELL U2720Q`; empty or a placeholder when unknown.
model: String,
builtin: bool,
}

#[cfg_attr(not(windows), allow(dead_code))]
fn device_tail(device: &str) -> &str {
device.rsplit('\\').next().unwrap_or(device)
}

/// `DISPLAY6` becomes `Display 6`, the way Windows writes it.
#[cfg_attr(not(windows), allow(dead_code))]
fn spelled(device: &str) -> String {
match device.strip_prefix("DISPLAY") {
Some(number) if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) => {
format!("Display {number}")
}
_ => device.to_owned(),
}
}

#[cfg_attr(not(windows), allow(dead_code))]
fn labels(names: &[String], monitors: &[Monitor]) -> std::collections::HashMap<String, String> {
let model = |name: &str| {
let monitor = monitors
.iter()
.find(|monitor| monitor.device.eq_ignore_ascii_case(name))?;
let model = monitor.model.trim();
if !model.is_empty() && !model.starts_with("Unknown Display") {
Some(model.to_owned())
} else if monitor.builtin {
Some("Built-in display".to_owned())
} else {
None
}
};

let found: Vec<(String, Option<String>)> = names
.iter()
.map(|name| (name.clone(), model(name)))
.collect();
found
.iter()
.map(|(name, model)| {
let label = match model {
// Two of the same monitor need the number to tell them apart.
Some(model)
if found
.iter()
.filter(|(_, other)| other.as_ref() == Some(model))
.count()
> 1 =>
{
format!("{model} ({})", spelled(name))
}
Some(model) => model.clone(),
None => spelled(name),
};
(name.clone(), label)
})
.collect()
}

#[cfg(test)]
mod tests {
use super::*;

fn monitor(device: &str, model: &str, builtin: bool) -> Monitor {
Monitor {
device: device_tail(device).to_owned(),
model: model.to_owned(),
builtin,
}
}

fn names(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_owned()).collect()
}

#[test]
fn a_monitor_is_called_by_its_model() {
let found = labels(
&names(&["DISPLAY6"]),
&[monitor(r"\\.\DISPLAY6", "DELL U2720Q", false)],
);
assert_eq!(
found.get("DISPLAY6").map(String::as_str),
Some("DELL U2720Q")
);
}

#[test]
fn two_of_the_same_model_keep_their_numbers() {
let found = labels(
&names(&["DISPLAY1", "DISPLAY2"]),
&[
monitor(r"\\.\DISPLAY1", "LG ULTRAGEAR", false),
monitor(r"\\.\DISPLAY2", "LG ULTRAGEAR", false),
],
);
assert_eq!(
found.get("DISPLAY2").map(String::as_str),
Some("LG ULTRAGEAR (Display 2)")
);
}

#[test]
fn an_unnamed_monitor_is_spelled_like_windows_does() {
let found = labels(
&names(&["DISPLAY3", "DISPLAY4", "desktop"]),
&[
monitor(r"\\.\DISPLAY3", "", true),
monitor(r"\\.\DISPLAY4", "Unknown Display 65537", false),
],
);
assert_eq!(
found.get("DISPLAY3").map(String::as_str),
Some("Built-in display")
);
assert_eq!(found.get("DISPLAY4").map(String::as_str), Some("Display 4"));
assert_eq!(found.get("desktop").map(String::as_str), Some("desktop"));
}
}
5 changes: 1 addition & 4 deletions crates/haru-apply/src/relaunch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,7 @@ impl Backend for Relaunch {
.and_then(|showing| showing.clone())
.filter(|_| launch::running())
});
Ok(vec![Screen {
name: DESKTOP.to_owned(),
current,
}])
Ok(vec![Screen::new(DESKTOP, current)])
}

fn apply(&self, screen: &str, dir: &Path) -> Result<(), String> {
Expand Down
20 changes: 14 additions & 6 deletions crates/haru-ui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,10 +336,16 @@ impl Haru {
self.restart_prompt(ui);
if !screens.is_empty() {
ui.add_space(10.0);
let chosen = self
.library
.target()
.map_or_else(|| "Screen".to_owned(), str::to_owned);
let chosen = self.library.target().map_or_else(
|| "Screen".to_owned(),
|target| {
screens
.iter()
.find(|screen| screen.name == target)
.map_or(target, |screen| screen.label.as_str())
.to_owned()
},
);
egui::ComboBox::from_id_salt("screen")
.selected_text(chosen)
.width(160.0)
Expand All @@ -351,8 +357,10 @@ impl Haru {
.as_ref()
.and_then(|dir| self.library.title_of(dir))
.unwrap_or_else(|| "nothing".to_owned());
let response = ui
.selectable_label(picked, format!("{} · {showing}", screen.name));
let response = ui.selectable_label(
picked,
format!("{} · {showing}", screen.label),
);
if response.clicked() {
self.library.set_target(screen.name.clone());
}
Expand Down
9 changes: 3 additions & 6 deletions crates/haru-ui/src/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,7 @@ impl Library {
self.screens = seen.screens;
for name in seen.connectors {
if !self.screens.iter().any(|screen| screen.name == name) {
self.screens.push(Screen {
name,
current: None,
});
self.screens.push(Screen::new(name, None));
}
}
if self
Expand Down Expand Up @@ -346,9 +343,9 @@ impl Library {
for screen in &self.screens {
let here = screen.current.as_deref() == Some(&item.dir);
let label = if here {
format!("On {}", screen.name)
format!("On {}", screen.label)
} else {
format!("Put up on {}", screen.name)
format!("Put up on {}", screen.label)
};
if ui.add_enabled(!here, egui::Button::new(label)).clicked() {
apply = Some((screen.name.clone(), item.dir.clone()));
Expand Down
4 changes: 2 additions & 2 deletions crates/haru-ui/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ impl Settings {
if ui
.add(
egui::TextEdit::singleline(&mut self.socket)
.hint_text("/run/user/1000/lwe.sock")
.hint_text(haru_apply::default_socket().display().to_string())
.desired_width(420.0),
)
.lost_focus()
Expand All @@ -508,7 +508,7 @@ impl Settings {
let names: Vec<&str> = engine
.screens
.iter()
.map(|screen| screen.name.as_str())
.map(|screen| screen.label.as_str())
.collect();
if !names.is_empty() {
ui.add_space(2.0);
Expand Down
15 changes: 13 additions & 2 deletions crates/haru-ui/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,15 @@ fn load_fonts(ctx: &egui::Context) {
pub fn apply(ctx: &egui::Context) {
load_fonts(ctx);

let mut style = (*ctx.style()).clone();
// haru is dark only. egui keeps a separate style per theme and, by
// default, follows the system's: on Windows in light mode it switched to
// its stock light style on the first frame, which painted light buttons,
// checkboxes and fields with haru's light text on them, and dark headings
// on haru's dark panels. Linux rarely reports a theme, so it never showed.
ctx.set_theme(egui::Theme::Dark);
ctx.send_viewport_cmd(egui::ViewportCommand::SetTheme(egui::SystemTheme::Dark));

let mut style = (*ctx.style_of(egui::Theme::Dark)).clone();
let visuals = &mut style.visuals;

*visuals = egui::Visuals::dark();
Expand Down Expand Up @@ -133,7 +141,10 @@ pub fn apply(ctx: &egui::Context) {
style.spacing.scroll.bar_width = 8.0;
style.spacing.combo_height = 360.0;

ctx.set_style(style);
// The light slot gets the same style, so nothing can bring egui's back.
let style = std::sync::Arc::new(style);
ctx.set_style_of(egui::Theme::Dark, style.clone());
ctx.set_style_of(egui::Theme::Light, style);
}

pub fn card(rounding: f32) -> egui::Frame {
Expand Down
Loading
Loading