niri-ipc: Add window positions and sizes (#1265)

* Add window sizes and positions to the IPC

* basic fixes

* report window_loc instead of window pos

* clean ups

* make scrolling indices 1-based

* add printing to niri msg windows

* don't include render offset in floating tile pos

---------

Co-authored-by: Ivan Molodetskikh <yalterz@gmail.com>
This commit is contained in:
yrkv
2025-08-16 01:42:08 -07:00
committed by GitHub
parent a003e01307
commit af30cc8df6
11 changed files with 223 additions and 19 deletions
+53
View File
@@ -1155,6 +1155,54 @@ pub struct Window {
pub is_floating: bool,
/// Whether this window requests your attention.
pub is_urgent: bool,
/// Position- and size-related properties of the window.
pub layout: WindowLayout,
}
/// Position- and size-related properties of a [`Window`].
///
/// Optional properties will be unset for some windows, do not rely on them being present. Whether
/// some optional properties are present or absent for certain window types may change across niri
/// releases.
///
/// All sizes and positions are in *logical pixels* unless stated otherwise. Logical sizes may be
/// fractional. For example, at 1.25 monitor scale, a 2-physical-pixel-wide window border is 1.6
/// logical pixels wide.
///
/// This struct contains positions and sizes both for full tiles ([`Self::tile_size`],
/// [`Self::tile_pos_in_workspace_view`]) and the window geometry ([`Self::window_size`],
/// [`Self::window_offset_in_tile`]). For visual displays, use the tile properties, as they
/// correspond to what the user visually considers "window". The window properties on the other
/// hand are mainly useful when you need to know the underlying Wayland window sizes, e.g. for
/// application debugging.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct WindowLayout {
/// Location of a tiled window within a workspace: (column index, tile index in column).
///
/// The indices are 1-based, i.e. the leftmost column is at index 1 and the topmost tile in a
/// column is at index 1. This is consistent with [`Action::FocusColumn`] and
/// [`Action::FocusWindowInColumn`].
pub pos_in_scrolling_layout: Option<(usize, usize)>,
/// Size of the tile this window is in, including decorations like borders.
pub tile_size: (f64, f64),
/// Size of the window's visual geometry itself.
///
/// Does not include niri decorations like borders.
///
/// Currently, Wayland toplevel windows can only be integer-sized in logical pixels, even
/// though it doesn't necessarily align to physical pixels.
pub window_size: (i32, i32),
/// Tile position within the current view of the workspace.
///
/// This is the same "workspace view" as in gradients' `relative-to` in the niri config.
pub tile_pos_in_workspace_view: Option<(f64, f64)>,
/// Location of the window's visual geometry within its tile.
///
/// This includes things like border sizes. For fullscreened fixed-size windows this includes
/// the distance from the corner of the black backdrop to the corner of the (centered) window
/// contents.
pub window_offset_in_tile: (f64, f64),
}
/// Output configuration change result.
@@ -1331,6 +1379,11 @@ pub enum Event {
/// The new urgency state of the window.
urgent: bool,
},
/// The layout of one or more windows has changed.
WindowLayoutsChanged {
/// Pairs consisting of a window id and new layout information for the window.
changes: Vec<(u64, WindowLayout)>,
},
/// The configured keyboard layouts have changed.
KeyboardLayoutsChanged {
/// The new keyboard layout configuration.
+7
View File
@@ -189,6 +189,13 @@ impl EventStreamStatePart for WindowsState {
}
}
}
Event::WindowLayoutsChanged { changes } => {
for (id, update) in changes {
let win = self.windows.get_mut(&id);
let win = win.expect("changed window was missing from the map");
win.layout = update;
}
}
event => return Some(event),
}
None
+65 -1
View File
@@ -7,7 +7,7 @@ use niri_config::OutputName;
use niri_ipc::socket::Socket;
use niri_ipc::{
Event, KeyboardLayouts, LogicalOutput, Mode, Output, OutputConfigChanged, Overview, Request,
Response, Transform, Window,
Response, Transform, Window, WindowLayout,
};
use serde_json::json;
@@ -447,6 +447,9 @@ pub fn handle_msg(msg: Msg, json: bool) -> anyhow::Result<()> {
Event::WindowUrgencyChanged { id, urgent } => {
println!("Window {id}: urgency changed to {urgent}");
}
Event::WindowLayoutsChanged { changes } => {
println!("Window layouts changed: {changes:?}");
}
Event::KeyboardLayoutsChanged { keyboard_layouts } => {
println!("Keyboard layouts changed: {keyboard_layouts:?}");
}
@@ -612,4 +615,65 @@ fn print_window(window: &Window) {
} else {
println!(" Workspace ID: (none)");
}
let WindowLayout {
pos_in_scrolling_layout,
tile_size,
window_size,
tile_pos_in_workspace_view,
window_offset_in_tile,
} = window.layout;
println!(" Layout:");
println!(
" Tile size: {} x {}",
fmt_rounded(tile_size.0),
fmt_rounded(tile_size.1)
);
if let Some(pos) = pos_in_scrolling_layout {
println!(" Scrolling position: column {}, tile {}", pos.0, pos.1);
}
if let Some(pos) = tile_pos_in_workspace_view {
println!(
" Workspace-view position: {}, {}",
fmt_rounded(pos.0),
fmt_rounded(pos.1)
);
}
println!(" Window size: {} x {}", window_size.0, window_size.1);
println!(
" Window offset in tile: {} x {}",
fmt_rounded(window_offset_in_tile.0),
fmt_rounded(window_offset_in_tile.1)
);
}
fn fmt_rounded(x: f64) -> String {
let r = x.round();
if (r - x).abs() <= 0.005 {
format!("{r}")
} else {
format!("{x:.2}")
}
}
#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use super::*;
#[test]
fn test_fmt_rounded() {
assert_snapshot!(fmt_rounded(1.9), @"1.90");
assert_snapshot!(fmt_rounded(1.994), @"1.99");
assert_snapshot!(fmt_rounded(1.996), @"2");
assert_snapshot!(fmt_rounded(2.0), @"2");
assert_snapshot!(fmt_rounded(2.004), @"2");
assert_snapshot!(fmt_rounded(2.006), @"2.01");
assert_snapshot!(fmt_rounded(2.1), @"2.10");
}
}
+28 -5
View File
@@ -17,7 +17,8 @@ use futures_util::{select_biased, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, Fu
use niri_config::OutputName;
use niri_ipc::state::{EventStreamState, EventStreamStatePart as _};
use niri_ipc::{
Event, KeyboardLayouts, OutputConfigChanged, Overview, Reply, Request, Response, Workspace,
Event, KeyboardLayouts, OutputConfigChanged, Overview, Reply, Request, Response, WindowLayout,
Workspace,
};
use smithay::desktop::layer_map_for_output;
use smithay::input::pointer::{
@@ -477,7 +478,11 @@ async fn handle_event_stream_client(client: EventStreamClient) -> anyhow::Result
Ok(())
}
fn make_ipc_window(mapped: &Mapped, workspace_id: Option<WorkspaceId>) -> niri_ipc::Window {
fn make_ipc_window(
mapped: &Mapped,
workspace_id: Option<WorkspaceId>,
layout: WindowLayout,
) -> niri_ipc::Window {
with_toplevel_role(mapped.toplevel(), |role| niri_ipc::Window {
id: mapped.id().get(),
title: role.title.clone(),
@@ -487,6 +492,7 @@ fn make_ipc_window(mapped: &Mapped, workspace_id: Option<WorkspaceId>) -> niri_i
is_focused: mapped.is_focused(),
is_floating: mapped.is_floating(),
is_urgent: mapped.is_urgent(),
layout,
})
}
@@ -657,10 +663,12 @@ impl State {
let mut events = Vec::new();
let layout = &self.niri.layout;
let mut batch_change_layouts: Vec<(u64, WindowLayout)> = Vec::new();
// Check for window changes.
let mut seen = HashSet::new();
let mut focused_id = None;
layout.with_windows(|mapped, _, ws_id| {
layout.with_windows(|mapped, _, ws_id, window_layout| {
let id = mapped.id().get();
seen.insert(id);
@@ -669,7 +677,7 @@ impl State {
}
let Some(ipc_win) = state.windows.get(&id) else {
let window = make_ipc_window(mapped, ws_id);
let window = make_ipc_window(mapped, ws_id, window_layout);
events.push(Event::WindowOpenedOrChanged { window });
return;
};
@@ -683,11 +691,15 @@ impl State {
});
if changed {
let window = make_ipc_window(mapped, ws_id);
let window = make_ipc_window(mapped, ws_id, window_layout);
events.push(Event::WindowOpenedOrChanged { window });
return;
}
if ipc_win.layout != window_layout {
batch_change_layouts.push((id, window_layout));
}
if mapped.is_focused() && !ipc_win.is_focused {
events.push(Event::WindowFocusChanged { id: Some(id) });
}
@@ -698,6 +710,17 @@ impl State {
}
});
// It might make sense to push layout changes after closed windows (since windows about to
// be closed will occupy the same column/tile positions as the window that moved into this
// vacated space), but also we are already pushing some layout changes in
// WindowOpenedOrChanged above, meaning that the receiving end has to handle this case
// anyway.
if !batch_change_layouts.is_empty() {
events.push(Event::WindowLayoutsChanged {
changes: batch_change_layouts,
});
}
// Check for closed windows.
let mut ipc_focused_id = None;
for (id, ipc_win) in &state.windows {
+17 -1
View File
@@ -3,7 +3,7 @@ use std::iter::zip;
use std::rc::Rc;
use niri_config::{PresetSize, RelativeTo};
use niri_ipc::{PositionChange, SizeChange};
use niri_ipc::{PositionChange, SizeChange, WindowLayout};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::utils::{Logical, Point, Rectangle, Scale, Serial, Size};
@@ -322,6 +322,22 @@ impl<W: LayoutElement> FloatingSpace<W> {
})
}
pub fn tiles_with_ipc_layouts(&self) -> impl Iterator<Item = (&Tile<W>, WindowLayout)> {
let scale = self.scale;
self.tiles_with_offsets().map(move |(tile, offset)| {
// Do not include animated render offset here to avoid IPC spam.
let pos = offset;
// Round to physical pixels.
let pos = pos.to_physical_precise_round(scale).to_logical(scale);
let layout = WindowLayout {
tile_pos_in_workspace_view: Some(pos.into()),
..tile.ipc_layout_template()
};
(tile, layout)
})
}
pub fn new_window_toplevel_bounds(&self, rules: &ResolvedWindowRules) -> Size<i32, Logical> {
let border_config = rules.border.resolve_against(self.options.border);
compute_toplevel_bounds(border_config, self.working_area.size)
+12 -7
View File
@@ -42,7 +42,7 @@ use niri_config::{
CenterFocusedColumn, Config, CornerRadius, FloatOrInt, PresetSize, Struts,
Workspace as WorkspaceConfig, WorkspaceReference,
};
use niri_ipc::{ColumnDisplay, PositionChange, SizeChange};
use niri_ipc::{ColumnDisplay, PositionChange, SizeChange, WindowLayout};
use scrolling::{Column, ColumnWidth};
use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
use smithay::backend::renderer::element::utils::RescaleRenderElement;
@@ -1742,25 +1742,30 @@ impl<W: LayoutElement> Layout<W> {
moving_window.chain(mon_windows)
}
pub fn with_windows(&self, mut f: impl FnMut(&W, Option<&Output>, Option<WorkspaceId>)) {
pub fn with_windows(
&self,
mut f: impl FnMut(&W, Option<&Output>, Option<WorkspaceId>, WindowLayout),
) {
if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move {
f(move_.tile.window(), Some(&move_.output), None);
// We don't fill any positions for interactively moved windows.
let layout = move_.tile.ipc_layout_template();
f(move_.tile.window(), Some(&move_.output), None, layout);
}
match &self.monitor_set {
MonitorSet::Normal { monitors, .. } => {
for mon in monitors {
for ws in &mon.workspaces {
for win in ws.windows() {
f(win, Some(&mon.output), Some(ws.id()));
for (tile, layout) in ws.tiles_with_ipc_layouts() {
f(tile.window(), Some(&mon.output), Some(ws.id()), layout);
}
}
}
}
MonitorSet::NoOutputs { workspaces } => {
for ws in workspaces {
for win in ws.windows() {
f(win, None, Some(ws.id()));
for (tile, layout) in ws.tiles_with_ipc_layouts() {
f(tile.window(), None, Some(ws.id()), layout);
}
}
}
+17 -1
View File
@@ -4,7 +4,7 @@ use std::rc::Rc;
use std::time::Duration;
use niri_config::{CenterFocusedColumn, PresetSize, Struts};
use niri_ipc::{ColumnDisplay, SizeChange};
use niri_ipc::{ColumnDisplay, SizeChange, WindowLayout};
use ordered_float::NotNan;
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::utils::{Logical, Point, Rectangle, Scale, Serial, Size};
@@ -2366,6 +2366,22 @@ impl<W: LayoutElement> ScrollingSpace<W> {
})
}
pub fn tiles_with_ipc_layouts(&self) -> impl Iterator<Item = (&Tile<W>, WindowLayout)> {
self.columns
.iter()
.enumerate()
.flat_map(move |(col_idx, col)| {
col.tiles().enumerate().map(move |(tile_idx, (tile, _))| {
let layout = WindowLayout {
// Our indices are 1-based, consistent with the actions.
pos_in_scrolling_layout: Some((col_idx + 1, tile_idx + 1)),
..tile.ipc_layout_template()
};
(tile, layout)
})
})
}
pub(super) fn insert_hint_area(
&self,
position: InsertPosition,
+14
View File
@@ -2,6 +2,7 @@ use core::f64;
use std::rc::Rc;
use niri_config::{Color, CornerRadius, GradientInterpolation};
use niri_ipc::WindowLayout;
use smithay::backend::renderer::element::{Element, Kind};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::utils::{Logical, Point, Rectangle, Scale, Size};
@@ -688,6 +689,19 @@ impl<W: LayoutElement> Tile<W> {
loc
}
/// Returns a partially-filled [`WindowLayout`].
///
/// Only the sizing properties that a [`Tile`] can fill are filled.
pub fn ipc_layout_template(&self) -> WindowLayout {
WindowLayout {
pos_in_scrolling_layout: None,
tile_size: self.tile_size().into(),
window_size: self.window().size().into(),
tile_pos_in_workspace_view: None,
window_offset_in_tile: self.window_loc().into(),
}
}
fn is_in_input_region(&self, mut point: Point<f64, Logical>) -> bool {
point -= self.window_loc().to_f64();
self.window.is_in_input_region(point)
+7 -1
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use niri_config::{
CenterFocusedColumn, CornerRadius, OutputName, PresetSize, Workspace as WorkspaceConfig,
};
use niri_ipc::{ColumnDisplay, PositionChange, SizeChange};
use niri_ipc::{ColumnDisplay, PositionChange, SizeChange, WindowLayout};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::desktop::{layer_map_for_output, Window};
use smithay::output::Output;
@@ -1427,6 +1427,12 @@ impl<W: LayoutElement> Workspace<W> {
floating.chain(scrolling)
}
pub fn tiles_with_ipc_layouts(&self) -> impl Iterator<Item = (&Tile<W>, WindowLayout)> {
let scrolling = self.scrolling.tiles_with_ipc_layouts();
let floating = self.floating.tiles_with_ipc_layouts();
floating.chain(scrolling)
}
pub fn active_tile_visual_rectangle(&self) -> Option<Rectangle<f64, Logical>> {
if self.floating_is_active.get() {
self.floating.active_tile_visual_rectangle()
+2 -2
View File
@@ -2208,7 +2208,7 @@ impl State {
},
);
self.niri.layout.with_windows(|mapped, _, _| {
self.niri.layout.with_windows(|mapped, _, _, _| {
let id = mapped.id().get();
let props = with_toplevel_role(mapped.toplevel(), |role| {
gnome_shell_introspect::WindowProperties {
@@ -3998,7 +3998,7 @@ impl Niri {
let mut seen = HashSet::new();
let mut output_changed = vec![];
self.layout.with_windows(|mapped, output, _| {
self.layout.with_windows(|mapped, output, _, _| {
seen.insert(mapped.window.clone());
let Some(output) = output else {
+1 -1
View File
@@ -93,7 +93,7 @@ pub fn refresh(state: &mut State) {
// Save the focused window for last, this way when the focus changes, we will first deactivate
// the previous window and only then activate the newly focused window.
let mut focused = None;
state.niri.layout.with_windows(|mapped, output, _| {
state.niri.layout.with_windows(|mapped, output, _, _| {
let toplevel = mapped.toplevel();
let wl_surface = toplevel.wl_surface();
with_toplevel_role(toplevel, |role| {