From c8f82719657b29b7ddd4e15716485d3ea29039a0 Mon Sep 17 00:00:00 2001 From: Roddy <49699333+Roddygithub@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:04:13 +0200 Subject: [PATCH] feat(overlay): let the user pick which monitor shows the overlay Adds a monitor selection setting (primary / cursor / index:N) to the overlay config and the Vencord plugin. The layer-shell window is assigned with gtk4_layer_shell::set_monitor() at startup and re-resolved on every settings message. Older clients that omit the field still deserialize (defaults to Primary); is_valid() accepts only known monitor values. Closes #21 --- memory-bank/progress.md | 17 +++++++++++++ overlay/src/config.rs | 49 ++++++++++++++++++++++++++++++++++++++ overlay/src/layer_shell.rs | 47 +++++++++++++++++++++++++++++++++++- overlay/src/main.rs | 3 ++- plugin/src/index.ts | 14 +++++++++++ 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 1348e62..c28fc40 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -717,3 +717,20 @@ Fixed by step-level gating + version step ordering. actionlint clean. Post-triage state: full CI + CodeQL green on main; dependabot now emits one grouped minor/patch PR per ecosystem per month. + +## Monitor selection — 2026-10-01 (issue #21) + +Overlay can now target a specific monitor instead of the compositor's default. + +- `MonitorConfig` enum in `config.rs`: `Primary` (default), `Active`/`Cursor` + (monitor under the pointer, via `seat.pointer().surface_at_position()` + + `display.monitor_at_surface()`), `Index(n)` (wrapped, `index:N` in config). +- `layer_shell.rs`: `resolve_monitor()` + `create_layer_shell_window` sets + `gtk4_layer_shell::set_monitor()` at startup; `update_monitor()` re-resolves + on every settings message so live changes move the overlay. +- `OverlaySettings.monitor` (string, `#[serde(default)]`) validated in + `is_valid()` — `""`/`primary`/`active`/`cursor`/`index:*` accepted; older + clients that omit the field still deserialize (defaults to `Primary`). +- Plugin (`plugin/src/index.ts`): new `monitor` SELECT setting (primary / + cursor / index 0-3) sent in `sendSettings()`. +- Gates: cargo fmt/clippy/test(40)/build, plugin eslint/vitest(25) — all green. diff --git a/overlay/src/config.rs b/overlay/src/config.rs index 63e5c42..eec3f21 100644 --- a/overlay/src/config.rs +++ b/overlay/src/config.rs @@ -39,6 +39,32 @@ pub enum AvatarSizeMode { Large, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum MonitorConfig { + #[default] + Primary, + Active, + Cursor, + Index(u32), +} + +impl MonitorConfig { + pub fn from_str(s: &str) -> Self { + match s { + "primary" => MonitorConfig::Primary, + "active" => MonitorConfig::Active, + "cursor" => MonitorConfig::Cursor, + s if s.starts_with("index:") => s + .strip_prefix("index:") + .and_then(|n| n.parse().ok()) + .map(MonitorConfig::Index) + .unwrap_or(MonitorConfig::Primary), + _ => MonitorConfig::Primary, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OverlayConfig { #[serde(default = "default_true")] @@ -57,6 +83,8 @@ pub struct OverlayConfig { pub name_display: NameDisplayMode, #[serde(default)] pub avatar_size_mode: AvatarSizeMode, + #[serde(default)] + pub monitor: MonitorConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -68,6 +96,8 @@ pub struct OverlaySettings { pub user_display: UserDisplayMode, pub name_display: NameDisplayMode, pub avatar_size_mode: AvatarSizeMode, + #[serde(default)] + pub monitor: String, } impl OverlaySettings { @@ -77,6 +107,11 @@ impl OverlaySettings { "top-left" | "top-right" | "bottom-left" | "bottom-right" | "center" | "custom" ) && (-32_768..=32_768).contains(&self.custom_x) && (-32_768..=32_768).contains(&self.custom_y) + && (self.monitor.is_empty() + || self.monitor == "primary" + || self.monitor == "active" + || self.monitor == "cursor" + || self.monitor.starts_with("index:")) } } @@ -91,6 +126,7 @@ impl Default for OverlayConfig { user_display: UserDisplayMode::default(), name_display: NameDisplayMode::default(), avatar_size_mode: AvatarSizeMode::default(), + monitor: MonitorConfig::default(), } } } @@ -157,6 +193,7 @@ impl Config { self.overlay.user_display = settings.user_display; self.overlay.name_display = settings.name_display; self.overlay.avatar_size_mode = settings.avatar_size_mode; + self.overlay.monitor = MonitorConfig::from_str(&settings.monitor); } pub fn avatar_size_px(&self) -> i32 { @@ -194,6 +231,7 @@ mod tests { user_display: UserDisplayMode::Always, name_display: NameDisplayMode::Always, avatar_size_mode: AvatarSizeMode::Large, + monitor: "cursor".into(), }; config.apply_overlay_settings(settings); @@ -203,6 +241,7 @@ mod tests { assert_eq!(config.overlay.custom_y, 300); assert_eq!(config.overlay.avatar_size_mode, AvatarSizeMode::Large); assert_eq!(config.avatar_size_px(), 40); + assert_eq!(config.overlay.monitor, MonitorConfig::Cursor); } #[test] @@ -231,4 +270,14 @@ mod tests { assert_eq!(config.overlay.max_participants, 7); assert_eq!(config.overlay.avatar_size_mode, AvatarSizeMode::Small); } + + #[test] + fn monitor_config_parsing() { + assert_eq!(MonitorConfig::from_str("primary"), MonitorConfig::Primary); + assert_eq!(MonitorConfig::from_str("active"), MonitorConfig::Active); + assert_eq!(MonitorConfig::from_str("cursor"), MonitorConfig::Cursor); + assert_eq!(MonitorConfig::from_str("index:0"), MonitorConfig::Index(0)); + assert_eq!(MonitorConfig::from_str("index:2"), MonitorConfig::Index(2)); + assert_eq!(MonitorConfig::from_str("unknown"), MonitorConfig::Primary); + } } diff --git a/overlay/src/layer_shell.rs b/overlay/src/layer_shell.rs index cf9c28a..cf37fe1 100644 --- a/overlay/src/layer_shell.rs +++ b/overlay/src/layer_shell.rs @@ -1,9 +1,11 @@ use anyhow::Result; +use gdk4::prelude::*; +use gdk4::Display; use gtk4::prelude::*; use gtk4::Application; use gtk4_layer_shell::{Layer, LayerShell as _}; -use crate::config::Config; +use crate::config::{Config, MonitorConfig}; /// Pointer pass-through: an empty input region makes the Wayland compositor /// deliver all pointer input to surfaces below the overlay while it stays @@ -20,6 +22,33 @@ pub fn apply_click_through(window: >k4::ApplicationWindow) { tracing::debug!("Applied empty input region (pointer click-through)"); } +fn monitor_at_cursor(display: &Display) -> Option { + let seat = display.default_seat()?; + let device = seat.pointer()?; + let (surface, _, _) = device.surface_at_position(); + display.monitor_at_surface(&surface?) +} + +fn resolve_monitor(monitor_config: &MonitorConfig) -> Option { + let display = Display::default()?; + let monitors = display.monitors(); + let n = monitors.n_items(); + if n == 0 { + return None; + } + + let first = || monitors.item(0).and_downcast::(); + + match monitor_config { + MonitorConfig::Primary => first(), + MonitorConfig::Active | MonitorConfig::Cursor => monitor_at_cursor(&display).or_else(first), + MonitorConfig::Index(idx) => { + let idx = *idx % n; + monitors.item(idx).and_downcast::() + } + } +} + pub fn create_layer_shell_window( app: &Application, config: &Config, @@ -41,6 +70,12 @@ pub fn create_layer_shell_window( config.overlay.custom_y, ); window.set_exclusive_zone(0); + + if let Some(monitor) = resolve_monitor(&config.overlay.monitor) { + window.set_monitor(Some(&monitor)); + tracing::debug!("Overlay assigned to monitor"); + } + // GTK auto-sizes the window from the ScrolledWindow's natural content // height (propagate_natural_height). No fixed size: the background // panel wraps exactly the visible participant rows. @@ -68,6 +103,16 @@ pub fn update_position( set_anchors(window, position, custom_x, custom_y); } +pub fn update_monitor(window: >k4::ApplicationWindow, monitor_config: &MonitorConfig) { + if let Some(monitor) = resolve_monitor(monitor_config) { + window.set_monitor(Some(&monitor)); + tracing::debug!("Overlay moved to monitor"); + } else { + window.set_monitor(None); + tracing::debug!("Overlay monitor reset to default"); + } +} + fn set_anchors(window: >k4::ApplicationWindow, position: &str, custom_x: i32, custom_y: i32) { use gtk4_layer_shell::{Edge, LayerShell as _}; diff --git a/overlay/src/main.rs b/overlay/src/main.rs index 8375ddf..6644f9b 100644 --- a/overlay/src/main.rs +++ b/overlay/src/main.rs @@ -17,7 +17,7 @@ use tracing::{error, info, warn}; use tracing_subscriber::prelude::*; use crate::config::Config; -use crate::layer_shell::{create_layer_shell_window, update_position}; +use crate::layer_shell::{create_layer_shell_window, update_monitor, update_position}; use crate::lifecycle::{OverlayCommand, OverlayLifecycle}; use crate::socket_server::SocketServer; use crate::ui::OverlayUI; @@ -176,6 +176,7 @@ fn run_application(app: &Application) -> Result<()> { settings.custom_x, settings.custom_y, ); + update_monitor(&window_clone, &config.overlay.monitor); if ui.update_settings(settings) { if !window_clone.is_visible() { window_clone.present(); diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 7f4139c..9df192c 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -30,6 +30,7 @@ function sendSettings() { user_display: settings.store.userDisplay, name_display: settings.store.nameDisplay, avatar_size_mode: settings.store.avatarSize, + monitor: settings.store.monitor, }, })); } @@ -100,6 +101,19 @@ const settings = definePluginSettings({ default: 20, onChange: activateCustomPosition, }, + monitor: { + type: OptionType.SELECT, + description: "Which monitor to display the overlay on", + options: [ + { label: "Primary", value: "primary", default: true }, + { label: "Cursor monitor", value: "cursor" }, + { label: "Monitor 0", value: "index:0" }, + { label: "Monitor 1", value: "index:1" }, + { label: "Monitor 2", value: "index:2" }, + { label: "Monitor 3", value: "index:3" }, + ], + onChange: sendSettings, + }, }); function sendSnapshot() {