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
17 changes: 17 additions & 0 deletions memory-bank/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
49 changes: 49 additions & 0 deletions overlay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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)]
Expand All @@ -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 {
Expand All @@ -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:"))
}
}

Expand All @@ -91,6 +126,7 @@ impl Default for OverlayConfig {
user_display: UserDisplayMode::default(),
name_display: NameDisplayMode::default(),
avatar_size_mode: AvatarSizeMode::default(),
monitor: MonitorConfig::default(),
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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]
Expand Down Expand Up @@ -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);
}
}
47 changes: 46 additions & 1 deletion overlay/src/layer_shell.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,6 +22,33 @@ pub fn apply_click_through(window: &gtk4::ApplicationWindow) {
tracing::debug!("Applied empty input region (pointer click-through)");
}

fn monitor_at_cursor(display: &Display) -> Option<gdk4::Monitor> {
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<gdk4::Monitor> {
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::<gdk4::Monitor>();

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::<gdk4::Monitor>()
}
}
}

pub fn create_layer_shell_window(
app: &Application,
config: &Config,
Expand All @@ -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.
Expand Down Expand Up @@ -68,6 +103,16 @@ pub fn update_position(
set_anchors(window, position, custom_x, custom_y);
}

pub fn update_monitor(window: &gtk4::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: &gtk4::ApplicationWindow, position: &str, custom_x: i32, custom_y: i32) {
use gtk4_layer_shell::{Edge, LayerShell as _};

Expand Down
3 changes: 2 additions & 1 deletion overlay/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}));
}
Expand Down Expand Up @@ -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() {
Expand Down
Loading