Compare commits

...

4 Commits

Author SHA1 Message Date
ce9f2196ca refactor: single greeter window, drop per-output keyboard grab (v0.10.0)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
The v0.8.0 → v0.8.5 multi-monitor story (login widget on primary only,
wallpaper-only on secondaries, KeyboardMode::Exclusive on the primary
surface, hotplug handler) was a pile of workarounds that kept breaking
on real hardware — after switching the greeter compositor to niri, the
pointer could not cross onto the primary output and keyboard tab did
not reach the UI. Niri in a normal user session has none of these
issues; the bug was moongreet's own output-scoped policy.

Back to basics: one layer-shell window on the built-in display,
KeyboardMode::OnDemand. Secondary outputs stay under compositor
control. No hotplug callbacks, no wallpaper-only windows, no DisplayLink
phantom workarounds.

-81 / +26 lines.
2026-04-24 11:27:48 +02:00
29ce185886 feat: apply cursor theme via GtkSettings instead of XCURSOR_THEME env (v0.9.0)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
GTK4 under greetd does not honour XCURSOR_THEME reliably — the env-prefix
hack in /etc/greetd/config.toml only reached the wlroots pointer in cage,
while GTK widgets kept using the default fallback cursor. Mirror the
existing gtk-theme handling: new cursor-theme + cursor-size fields in the
[appearance] section, applied via gtk::Settings::set_gtk_cursor_theme_*.
Keeps the fix scoped to the greeter, no system-wide GTK config changes.
2026-04-24 08:56:41 +02:00
91b4289748 fix: wallpaper-only windows on secondary monitors (v0.8.5)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
The v0.8.4 keyboard fix only half-worked: keys were still dropped
until the pointer moved to the built-in panel. Niri scopes layer-shell
keyboard routing per active output, so a single Exclusive surface is
not enough when another output is active.

Revert 2026-04-08 partially: only the built-in panel shows the login
widget, other monitors get a wallpaper-only window with
KeyboardMode::None. Hotplugged monitors also get wallpaper-only.
Compositor-agnostic — no Niri IPC.
2026-04-23 14:06:12 +02:00
97165d94f8 fix: keyboard focus on built-in display to avoid evdi phantom grab (v0.8.4)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 3s
DisplayLink/evdi virtual displays enumerate as DVI-I-* before eDP-1 and
were stealing the KeyboardMode::Exclusive grab on the first enumerated
monitor, leaving the visible greeter surfaces without keyboard input.

Introduce pick_primary_monitor_index() that prefers eDP/LVDS/DSI
connectors for the keyboard grab and falls back to index 0 when no
built-in panel is present. Pure, unit-tested; hotplug path unchanged.
2026-04-23 11:02:24 +02:00
9 changed files with 214 additions and 56 deletions

View File

@ -43,9 +43,9 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- `power.rs` — Reboot/Shutdown via loginctl - `power.rs` — Reboot/Shutdown via loginctl
- `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen - `i18n.rs` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN), alle UI- und Login-Fehlermeldungen
- `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback - `fingerprint.rs` — fprintd D-Bus Probe (gio::DBusProxy) — Geräteerkennung und Enrollment-Check für UI-Feedback
- `config.rs` — TOML-Config ([appearance] background, gtk-theme, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200) - `config.rs` — TOML-Config ([appearance] background, gtk-theme, cursor-theme, cursor-size, fingerprint-enabled) + Wallpaper-Fallback + Blur-Validierung (finite, clamp 0200) + Cursor-Size-Validierung (range 1256)
- `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files) - `greeter.rs` — GTK4 UI (Overlay-Layout), Login-Flow via greetd IPC (Multi-Stage-Auth für fprintd), Faillock-Warnung, Avatar-Cache, Last-User/Last-Session Persistence (0o700 Dirs, 0o600 Files).
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor mit Hotplug via `items-changed` auf Monitor-ListModel (one greeter window per monitor, first gets keyboard), systemd-journal-logger - `main.rs` — Entry Point, GTK App, Layer Shell Setup. Ein einziges Greeter-Fenster, verankert am Built-in-Display (via `pick_primary_monitor_index`), `KeyboardMode::OnDemand` — moongreet ist ein normaler layer-shell-client, keine output-scoped policies. Sekundäre Monitore bleiben unter Compositor-Kontrolle. Systemd-journal-logger.
- `resources/style.css` — Catppuccin-inspiriertes Theme - `resources/style.css` — Catppuccin-inspiriertes Theme
## Design Decisions ## Design Decisions
@ -60,9 +60,11 @@ cd pkg && makepkg -sf && sudo pacman -U moongreet-git-<version>-x86_64.pkg.tar.z
- **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur) - **Symmetrie mit moonlock/moonset**: Gleiche Patterns (i18n, config, users, power, GResource, GPU-Blur)
- **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt - **Session-Validierung**: Relative Pfade erlaubt (greetd löst PATH auf), nur `..`/Null-Bytes werden abgelehnt
- **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config - **GTK-Theme-Validierung**: Nur alphanumerisch + `_-+.` erlaubt, verhindert Path-Traversal über Config
- **Cursor-Theme via GtkSettings**: GTK4 unter greetd liest `XCURSOR_THEME` env nicht zuverlässig — Cursor wird via `gtk::Settings::set_gtk_cursor_theme_name()` gesetzt, analog zu `gtk-theme`. Gleiche Validierung (`is_valid_gtk_theme`) gegen Path-Traversal.
- **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var - **Journal-Logging**: `systemd-journal-logger` statt File-Logging — `journalctl -t moongreet`, Debug-Level per `MOONGREET_DEBUG` Env-Var
- **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600 - **File Permissions**: Cache-Verzeichnisse 0o700 via `DirBuilder::mode()`, Cache-Dateien 0o600
- **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests - **Testbare Persistence**: `save_*_to`/`load_*_from` Varianten mit konfigurierbarem Pfad für Unit-Tests
- **Shared Wallpaper Texture**: `gdk::Texture` wird einmal in `load_background_texture()` dekodiert und per Ref-Count an alle Fenster geteilt — vermeidet redundante JPEG-Dekodierung pro Monitor - **Shared Wallpaper Texture**: `gdk::Texture` wird einmal in `load_background_texture()` dekodiert und per Ref-Count an alle Fenster geteilt — vermeidet redundante JPEG-Dekodierung pro Monitor
- **Wallpaper-Validierung**: GResource-Zweig via `resources_lookup_data()` + `from_bytes()` (kein Abort bei fehlendem Pfad), Dateigröße-Limit 50 MB, non-UTF-8-Pfade → `None` - **Wallpaper-Validierung**: GResource-Zweig via `resources_lookup_data()` + `from_bytes()` (kein Abort bei fehlendem Pfad), Dateigröße-Limit 50 MB, non-UTF-8-Pfade → `None`
- **Error-Detail-Filterung**: GDK/greetd-Fehlerdetails nur auf `debug!`-Level, `warn!` ohne interne Details — verhindert Systeminfo-Leak ins Journal - **Error-Detail-Filterung**: GDK/greetd-Fehlerdetails nur auf `debug!`-Level, `warn!` ohne interne Details — verhindert Systeminfo-Leak ins Journal
- **Single Greeter Window, keine Output-Policies**: Ein einziges layer-shell-fenster auf dem Built-in-Display, `KeyboardMode::OnDemand`. Sekundäre Outputs bleiben unter Compositor-Kontrolle. Grund: Die output-scoped policies aus v0.8.0v0.8.5 (Exclusive-Keyboard auf Primary, Wallpaper-Only auf Secondaries, Hotplug-Callbacks) haben den Greeter bei realen Multi-Monitor-Setups wiederholt kaputt gemacht (Pointer kommt nicht zum Primary, Keyboard tabt nicht zur UI). Im User-Session-Niri gibt es diese Probleme nicht — moongreet verhält sich jetzt wie jeder normale layer-shell-client.

2
Cargo.lock generated
View File

@ -575,7 +575,7 @@ dependencies = [
[[package]] [[package]]
name = "moongreet" name = "moongreet"
version = "0.8.3" version = "0.9.0"
dependencies = [ dependencies = [
"gdk-pixbuf", "gdk-pixbuf",
"gdk4", "gdk4",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "moongreet" name = "moongreet"
version = "0.8.3" version = "0.10.0"
edition = "2024" edition = "2024"
description = "A greetd greeter for Wayland with GTK4 and Layer Shell" description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
license = "MIT" license = "MIT"

View File

@ -1,5 +1,33 @@
# Decisions # Decisions
## 2026-04-24 Single greeter window, no per-output keyboard grab (v0.10.0)
- **Who**: ClaudeCode, Dom
- **Why**: The v0.8.0 → v0.8.4 → v0.8.5 sequence accumulated multi-monitor logic (login widget on primary, wallpaper-only on secondaries, `KeyboardMode::Exclusive` on the primary surface) to work around keyboard-routing on specific hardware setups. After the moonarch greeter switched to niri, the symptoms returned: the UI was on eDP-1 but the pointer could not cross onto it, and keyboard tab did not reach the login widget. Niri in a normal user session never behaves like this — the issue was our greeter's self-imposed per-output scope, not the compositor. Every earlier "fix" made it more bespoke instead of making moongreet a well-behaved layer-shell client.
- **Tradeoffs**: Reverts the multi-output story entirely. Secondary monitors get nothing from moongreet — the compositor decides what renders there (black, its own wallpaper, whatever). The "wallpaper on every screen" look is gone. In exchange, cursor and keyboard follow normal niri focus rules, nothing is grabbed, no hotplug callbacks, no DisplayLink phantom workarounds.
- **How**: `main.rs::activate` builds **one** greeter window, anchors it to the built-in display picked by `pick_primary_monitor_index`, and calls `setup_layer_shell` with `KeyboardMode::OnDemand`. The hotplug `connect_items_changed` handler is gone. `create_wallpaper_window` is removed. `setup_layer_shell` no longer takes a `keyboard: bool` — there is only one policy.
## 2026-04-24 Cursor theme via config instead of env (v0.9.0)
- **Who**: ClaudeCode, Dom
- **Why**: Cursor theme in the greeter was the default fallback even with `XCURSOR_THEME=Sweet-cursors` in `/etc/greetd/config.toml`'s `env` prefix. Cage forwards the env, but GTK4 does not honour `XCURSOR_THEME` reliably under greetd — it picks up the theme from `gtk-cursor-theme-name` on `GtkSettings`, and without a session-level settings.ini or GSettings override in the greeter user's home, that property stays at the GTK default. Adding an env-var hack worked for the wlroots pointer rendered by cage, but GTK widgets (button hover, text input) used their own wrong cursor.
- **Tradeoffs**: Adds two config fields (`cursor-theme`, `cursor-size`) — symmetric with the existing `gtk-theme` field and justified by the same cause (GTK4 under greetd ignores the usual discovery paths). Alternative would have been a system-wide `/etc/gtk-4.0/settings.ini` with `gtk-cursor-theme-name=`, but that couples moongreet's appearance to the host system's GTK config and affects every GTK4 app running as any user.
- **How**: `config.rs` gains `cursor_theme: Option<String>` and `cursor_size: Option<i32>` (range-validated 1256). `greeter.rs::create_greeter_window` applies them via `gtk::Settings::set_gtk_cursor_theme_name()` and `set_gtk_cursor_theme_size()` directly after the existing `gtk-theme` handling, reusing `is_valid_gtk_theme()` for name validation. Moonarch's deployed config gains `cursor-theme = "Sweet-cursors"` + `cursor-size = 24`. The env-prefix hack in `/etc/greetd/config.toml` is now redundant.
## 2026-04-23 Wallpaper-only windows on secondary monitors (v0.8.5)
- **Who**: ClaudeCode, Dom
- **Why**: The v0.8.4 fix (keyboard grab on the built-in panel) only half-worked. The greeter still rejected keystrokes until the user moved the mouse to eDP-1 — Niri scopes layer-shell keyboard routing by active output, so even though the primary window was the sole `KeyboardMode::Exclusive` surface, keys went nowhere when another output was active. Hardcoding a compositor focus call (e.g. `niri msg action focus-monitor`) would tie moongreet to a specific compositor.
- **Tradeoffs**: Reverts part of 2026-04-08: only the built-in panel shows the full greeter UI, other monitors go back to wallpaper-only. Users with multiple monitors lose the symmetric "login widget on every screen" look, but gain a reliable keyboard path regardless of which output the compositor considers active at startup. Compositor-agnostic — no Niri-specific IPC.
- **How**: New `create_wallpaper_window()` in `greeter.rs` builds a minimal `ApplicationWindow` with the shared background `Picture` (same `blur_cache` as the primary) and no login widgets. `main.rs` uses `create_greeter_window()` for the index returned by `pick_primary_monitor_index()` and `create_wallpaper_window()` for the rest. Hotplugged monitors also get wallpaper-only windows. Both variants use `Layer::Top`; only the primary sets `KeyboardMode::Exclusive`.
## 2026-04-23 Keyboard focus on built-in display, not first enumerated monitor (v0.8.4)
- **Who**: ClaudeCode, Dom
- **Why**: With a DisplayLink dock attached, the greeter showed its UI on all monitors but the password entry accepted no input. `display.monitors()` enumerated evdi phantom connectors (`DVI-I-*`) before the laptop panel (`eDP-1`); the v0.8.0 logic gave `KeyboardMode::Exclusive` to index 0, so the keyboard grab landed on an invisible surface. Symptom showed up on 2026-04-23 after kernel 6.19.11 → 6.19.12 + moongreet 0.8.0 → 0.8.2 changed evdi enumeration timing — previous Thursdays with the same dock worked.
- **Tradeoffs**: Prefers built-in displays by connector-name pattern (`eDP*`/`LVDS*`/`DSI*`) rather than a generic "primary monitor" concept — Wayland has no portable primary signal, and gdk4's `primary_monitor()` was removed. Pattern-matching covers every current Linux laptop, at the cost of a tiny list to maintain if a new form factor ships a new connector type. Fallback is still index 0, so behavior on desktops without a built-in panel is unchanged.
- **How**: New pure function `pick_primary_monitor_index()` in `main.rs` scans connector names and returns the built-in index (or 0). Used during initial enumeration to decide which window gets `KeyboardMode::Exclusive`. Hotplug branch unchanged — new monitors still get keyboard=false so focus never migrates off the panel. Unit-tested against evdi/eDP/LVDS/DSI/HDMI/DP mixes.
## 2026-04-21 Ship polkit rule in moongreet instead of moonarch (v0.8.3) ## 2026-04-21 Ship polkit rule in moongreet instead of moonarch (v0.8.3)
- **Who**: ClaudeCode, Dom - **Who**: ClaudeCode, Dom

View File

@ -12,7 +12,7 @@ Part of the Moonarch ecosystem.
- **Last user/session** — Remembered in `/var/cache/moongreet/` - **Last user/session** — Remembered in `/var/cache/moongreet/`
- **Power actions** — Reboot / Shutdown via `loginctl` - **Power actions** — Reboot / Shutdown via `loginctl`
- **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer) - **Layer Shell** — Fullscreen via gtk4-layer-shell (TOP layer)
- **Multi-monitor + hotplug**Full greeter UI on all monitors (keyboard input on first), hotplugged monitors get windows automatically - **Multi-monitor + hotplug**Login UI on the built-in display, wallpaper-only on other monitors; hotplugged monitors get wallpaper windows automatically
- **GPU blur** — Background blur via GskBlurNode (shared cache across monitors) - **GPU blur** — Background blur via GskBlurNode (shared cache across monitors)
- **i18n** — German and English (auto-detected from system locale) - **i18n** — German and English (auto-detected from system locale)
- **Faillock warning** — Warns after 2 failed attempts, locked message after 3 - **Faillock warning** — Warns after 2 failed attempts, locked message after 3

View File

@ -8,3 +8,8 @@ background = "/usr/share/backgrounds/wallpaper.jpg"
# GTK theme name — must match a directory in /usr/share/themes/ # GTK theme name — must match a directory in /usr/share/themes/
# Required because GTK4 under greetd does not reliably read settings.ini # Required because GTK4 under greetd does not reliably read settings.ini
gtk-theme = "Colloid-Grey-Dark-Catppuccin" gtk-theme = "Colloid-Grey-Dark-Catppuccin"
# Cursor theme name — must match a directory in /usr/share/icons/
# GTK4 under greetd does not honour XCURSOR_THEME, so set it here.
cursor-theme = "Sweet-cursors"
cursor-size = 24

View File

@ -25,6 +25,10 @@ struct Appearance {
background_blur: Option<f32>, background_blur: Option<f32>,
#[serde(rename = "gtk-theme")] #[serde(rename = "gtk-theme")]
gtk_theme: Option<String>, gtk_theme: Option<String>,
#[serde(rename = "cursor-theme")]
cursor_theme: Option<String>,
#[serde(rename = "cursor-size")]
cursor_size: Option<i32>,
#[serde(rename = "fingerprint-enabled")] #[serde(rename = "fingerprint-enabled")]
fingerprint_enabled: Option<bool>, fingerprint_enabled: Option<bool>,
} }
@ -35,6 +39,8 @@ pub struct Config {
pub background_path: Option<String>, pub background_path: Option<String>,
pub background_blur: Option<f32>, pub background_blur: Option<f32>,
pub gtk_theme: Option<String>, pub gtk_theme: Option<String>,
pub cursor_theme: Option<String>,
pub cursor_size: Option<i32>,
pub fingerprint_enabled: bool, pub fingerprint_enabled: bool,
} }
@ -44,6 +50,8 @@ impl Default for Config {
background_path: None, background_path: None,
background_blur: None, background_blur: None,
gtk_theme: None, gtk_theme: None,
cursor_theme: None,
cursor_size: None,
fingerprint_enabled: true, fingerprint_enabled: true,
} }
} }
@ -82,6 +90,16 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
if appearance.gtk_theme.is_some() { if appearance.gtk_theme.is_some() {
merged.gtk_theme = appearance.gtk_theme; merged.gtk_theme = appearance.gtk_theme;
} }
if appearance.cursor_theme.is_some() {
merged.cursor_theme = appearance.cursor_theme;
}
if let Some(size) = appearance.cursor_size {
if (1..=256).contains(&size) {
merged.cursor_size = Some(size);
} else {
log::warn!("Ignoring cursor-size out of range (1256): {size}");
}
}
if let Some(fp) = appearance.fingerprint_enabled { if let Some(fp) = appearance.fingerprint_enabled {
merged.fingerprint_enabled = fp; merged.fingerprint_enabled = fp;
} }
@ -98,7 +116,15 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
} }
} }
log::debug!("Config result: background={:?}, blur={:?}, gtk_theme={:?}, fingerprint={}", merged.background_path, merged.background_blur, merged.gtk_theme, merged.fingerprint_enabled); log::debug!(
"Config result: background={:?}, blur={:?}, gtk_theme={:?}, cursor_theme={:?}, cursor_size={:?}, fingerprint={}",
merged.background_path,
merged.background_blur,
merged.gtk_theme,
merged.cursor_theme,
merged.cursor_size,
merged.fingerprint_enabled
);
merged merged
} }
@ -321,6 +347,40 @@ mod tests {
assert!(config.background_blur.is_none()); assert!(config.background_blur.is_none());
} }
// -- Cursor theme tests --
#[test]
fn load_config_cursor_theme_and_size() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(
&conf,
"[appearance]\ncursor-theme = \"Sweet-cursors\"\ncursor-size = 32\n",
)
.unwrap();
let config = load_config(Some(&[conf]));
assert_eq!(config.cursor_theme.as_deref(), Some("Sweet-cursors"));
assert_eq!(config.cursor_size, Some(32));
}
#[test]
fn load_config_cursor_size_out_of_range_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\ncursor-size = 9999\n").unwrap();
let config = load_config(Some(&[conf]));
assert!(config.cursor_size.is_none());
}
#[test]
fn load_config_cursor_size_zero_rejected() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moongreet.toml");
fs::write(&conf, "[appearance]\ncursor-size = 0\n").unwrap();
let config = load_config(Some(&[conf]));
assert!(config.cursor_size.is_none());
}
#[test] #[test]
fn load_config_blur_inf_rejected() { fn load_config_blur_inf_rejected() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View File

@ -259,6 +259,23 @@ pub fn create_greeter_window(
} }
} }
// Apply cursor theme from config — GTK4 under greetd does not read XCURSOR_THEME
// reliably, so set the gtk-cursor-theme-name property directly.
if let Some(ref cursor) = config.cursor_theme {
if is_valid_gtk_theme(cursor) {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_cursor_theme_name(Some(cursor));
}
} else {
log::warn!("Ignoring invalid cursor theme name: {cursor}");
}
}
if let Some(size) = config.cursor_size {
if let Some(settings) = gtk::Settings::default() {
settings.set_gtk_cursor_theme_size(size);
}
}
let strings = load_strings(None); let strings = load_strings(None);
let fingerprint_enabled = config.fingerprint_enabled; let fingerprint_enabled = config.fingerprint_enabled;
let all_users = users::get_users(None); let all_users = users::get_users(None);

View File

@ -11,11 +11,9 @@ mod sessions;
mod users; mod users;
use gdk4 as gdk; use gdk4 as gdk;
use glib::clone;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{self as gtk, gio}; use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell; use gtk4_layer_shell::LayerShell;
use std::rc::Rc;
fn load_css(display: &gdk::Display) { fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new(); let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moongreet/style.css"); css_provider.load_from_resource("/dev/moonarch/moongreet/style.css");
@ -26,20 +24,37 @@ fn load_css(display: &gdk::Display) {
); );
} }
fn setup_layer_shell(window: &gtk::ApplicationWindow, keyboard: bool, layer: gtk4_layer_shell::Layer) { fn setup_layer_shell(window: &gtk::ApplicationWindow, layer: gtk4_layer_shell::Layer) {
window.init_layer_shell(); window.init_layer_shell();
window.set_layer(layer); window.set_layer(layer);
window.set_exclusive_zone(-1); window.set_exclusive_zone(-1);
if keyboard { window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::OnDemand);
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
}
// Anchor to all edges for fullscreen
window.set_anchor(gtk4_layer_shell::Edge::Top, true); window.set_anchor(gtk4_layer_shell::Edge::Top, true);
window.set_anchor(gtk4_layer_shell::Edge::Bottom, true); window.set_anchor(gtk4_layer_shell::Edge::Bottom, true);
window.set_anchor(gtk4_layer_shell::Edge::Left, true); window.set_anchor(gtk4_layer_shell::Edge::Left, true);
window.set_anchor(gtk4_layer_shell::Edge::Right, true); window.set_anchor(gtk4_layer_shell::Edge::Right, true);
} }
/// Pick the index of the built-in display among the given connector names.
///
/// Prefers `eDP*` / `LVDS*` / `DSI*` over anything else — otherwise keyboard
/// focus can land on DisplayLink/evdi phantom displays (connector `DVI-I-*`)
/// that are enumerated before the laptop panel. Falls back to index 0 when
/// no built-in connector is present.
fn pick_primary_monitor_index<'a, I>(connectors: I) -> usize
where
I: IntoIterator<Item = Option<&'a str>>,
{
for (i, conn) in connectors.into_iter().enumerate() {
if let Some(c) = conn
&& (c.starts_with("eDP") || c.starts_with("LVDS") || c.starts_with("DSI"))
{
return i;
}
}
0
}
fn activate(app: &gtk::Application) { fn activate(app: &gtk::Application) {
let display = match gdk::Display::default() { let display = match gdk::Display::default() {
Some(d) => d, Some(d) => d,
@ -66,51 +81,41 @@ fn activate(app: &gtk::Application) {
log::debug!("Layer shell: {use_layer_shell}"); log::debug!("Layer shell: {use_layer_shell}");
if use_layer_shell { if use_layer_shell {
// One greeter window per monitor — only the first gets keyboard input // Single greeter window anchored to the built-in display. Other
// outputs stay under compositor control — the greeter is just a
// normal layer-shell client, no per-output keyboard grabs.
let monitors = display.monitors(); let monitors = display.monitors();
log::debug!("Monitor count: {}", monitors.n_items()); let count = monitors.n_items();
let mut first = true; log::debug!("Monitor count: {count}");
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors
.item(i)
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
{
let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
setup_layer_shell(&window, first, gtk4_layer_shell::Layer::Top);
window.set_monitor(Some(&monitor));
window.present();
first = false;
}
}
// Handle monitor hotplug — create greeter windows for newly added monitors let connectors: Vec<Option<String>> = (0..count)
// (without keyboard, since the primary monitor already has it) .map(|i| {
let bg_texture = Rc::new(bg_texture); monitors
let config = Rc::new(config); .item(i)
monitors.connect_items_changed(clone!( .and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
#[weak] .and_then(|m| m.connector())
app, .map(|gs| gs.to_string())
#[strong] })
blur_cache, .collect();
move |list, position, _removed, added| {
for i in position..position + added { let primary_idx = pick_primary_monitor_index(connectors.iter().map(|o| o.as_deref()));
if let Some(monitor) = list log::debug!(
.item(i) "Primary monitor: idx={primary_idx} connector={:?}",
.and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) connectors.get(primary_idx).and_then(|o| o.as_deref())
{ );
log::debug!("Monitor hotplug: creating greeter window");
let window = greeter::create_greeter_window( if let Some(monitor) = monitors
bg_texture.as_ref().as_ref(), &config, &blur_cache, &app, .item(primary_idx as u32)
); .and_then(|obj| obj.downcast::<gdk::Monitor>().ok())
setup_layer_shell(&window, false, gtk4_layer_shell::Layer::Top); {
window.set_monitor(Some(&monitor)); let window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
window.present(); setup_layer_shell(&window, gtk4_layer_shell::Layer::Top);
} window.set_monitor(Some(&monitor));
} window.present();
} } else {
)); log::error!("Primary monitor {primary_idx} not available — greeter will not be shown");
}
} else { } else {
// No layer shell — single window for development
let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app); let greeter_window = greeter::create_greeter_window(bg_texture.as_ref(), &config, &blur_cache, app);
greeter_window.present(); greeter_window.present();
} }
@ -135,6 +140,47 @@ fn setup_logging() {
log::set_max_level(level); log::set_max_level(level);
} }
#[cfg(test)]
mod tests {
use super::pick_primary_monitor_index;
#[test]
fn prefers_edp_over_phantom_dvi() {
assert_eq!(
pick_primary_monitor_index([Some("DVI-I-1"), Some("eDP-1"), Some("DVI-I-2")]),
1,
);
}
#[test]
fn prefers_lvds() {
assert_eq!(pick_primary_monitor_index([Some("HDMI-A-1"), Some("LVDS-1")]), 1);
}
#[test]
fn prefers_dsi() {
assert_eq!(pick_primary_monitor_index([Some("DP-1"), Some("DSI-1")]), 1);
}
#[test]
fn falls_back_to_first_without_builtin() {
assert_eq!(
pick_primary_monitor_index([Some("DVI-I-1"), Some("HDMI-A-1")]),
0,
);
}
#[test]
fn skips_missing_connector() {
assert_eq!(pick_primary_monitor_index([None, Some("eDP-1")]), 1);
}
#[test]
fn empty_returns_zero() {
assert_eq!(pick_primary_monitor_index(std::iter::empty::<Option<&str>>()), 0);
}
}
fn main() { fn main() {
setup_logging(); setup_logging();
log::info!("Moongreet starting"); log::info!("Moongreet starting");