Implement toggle-windowed-fullscreen

Windowed, or fake, or detached, fullscreen, is when a window thinks that it's
fullscreen, but the compositor treats it as a normal window.
This commit is contained in:
Ivan Molodetskikh
2025-03-17 14:56:29 +03:00
parent b447b1f4de
commit 39f52b7585
6 changed files with 246 additions and 4 deletions

View File

@@ -1476,6 +1476,9 @@ pub enum Action {
FullscreenWindow,
#[knuffel(skip)]
FullscreenWindowById(u64),
ToggleWindowedFullscreen,
#[knuffel(skip)]
ToggleWindowedFullscreenById(u64),
#[knuffel(skip)]
FocusWindow(u64),
FocusWindowInColumn(#[knuffel(argument)] u8),
@@ -1680,6 +1683,12 @@ impl From<niri_ipc::Action> for Action {
niri_ipc::Action::CloseWindow { id: Some(id) } => Self::CloseWindowById(id),
niri_ipc::Action::FullscreenWindow { id: None } => Self::FullscreenWindow,
niri_ipc::Action::FullscreenWindow { id: Some(id) } => Self::FullscreenWindowById(id),
niri_ipc::Action::ToggleWindowedFullscreen { id: None } => {
Self::ToggleWindowedFullscreen
}
niri_ipc::Action::ToggleWindowedFullscreen { id: Some(id) } => {
Self::ToggleWindowedFullscreenById(id)
}
niri_ipc::Action::FocusWindow { id } => Self::FocusWindow(id),
niri_ipc::Action::FocusWindowInColumn { index } => Self::FocusWindowInColumn(index),
niri_ipc::Action::FocusWindowPrevious {} => Self::FocusWindowPrevious,

View File

@@ -221,6 +221,18 @@ pub enum Action {
#[cfg_attr(feature = "clap", arg(long))]
id: Option<u64>,
},
/// Toggle windowed (fake) fullscreen on a window.
#[cfg_attr(
feature = "clap",
clap(about = "Toggle windowed (fake) fullscreen on the focused window")
)]
ToggleWindowedFullscreen {
/// Id of the window to toggle windowed fullscreen of.
///
/// If `None`, uses the focused window.
#[cfg_attr(feature = "clap", arg(long))]
id: Option<u64>,
},
/// Focus a window by id.
FocusWindow {
/// Id of the window to focus.

View File

@@ -690,6 +690,23 @@ impl State {
self.niri.queue_redraw_all();
}
}
Action::ToggleWindowedFullscreen => {
let focus = self.niri.layout.focus().map(|m| m.window.clone());
if let Some(window) = focus {
self.niri.layout.toggle_windowed_fullscreen(&window);
// FIXME: granular
self.niri.queue_redraw_all();
}
}
Action::ToggleWindowedFullscreenById(id) => {
let window = self.niri.layout.windows().find(|(_, m)| m.id().get() == id);
let window = window.map(|(_, m)| m.window.clone());
if let Some(window) = window {
self.niri.layout.toggle_windowed_fullscreen(&window);
// FIXME: granular
self.niri.queue_redraw_all();
}
}
Action::FocusWindow(id) => {
let window = self.niri.layout.windows().find(|(_, m)| m.id().get() == id);
let window = window.map(|(_, m)| m.window.clone());

View File

@@ -244,6 +244,13 @@ pub trait LayoutElement {
Some(requested)
}
fn is_pending_windowed_fullscreen(&self) -> bool {
false
}
fn request_windowed_fullscreen(&mut self, value: bool) {
let _ = value;
}
fn is_child_of(&self, parent: &Self) -> bool;
fn rules(&self) -> &ResolvedWindowRules;
@@ -3458,6 +3465,20 @@ impl<W: LayoutElement> Layout<W> {
}
pub fn set_fullscreen(&mut self, id: &W::Id, is_fullscreen: bool) {
// Check if this is a request to unset the windowed fullscreen state.
if !is_fullscreen {
let mut handled = false;
self.with_windows_mut(|window, _| {
if window.id() == id && window.is_pending_windowed_fullscreen() {
window.request_windowed_fullscreen(false);
handled = true;
}
});
if handled {
return;
}
}
if let Some(InteractiveMoveState::Moving(move_)) = &self.interactive_move {
if move_.tile.window().id() == id {
return;
@@ -3487,6 +3508,26 @@ impl<W: LayoutElement> Layout<W> {
}
}
pub fn toggle_windowed_fullscreen(&mut self, id: &W::Id) {
let (_, window) = self.windows().find(|(_, win)| win.id() == id).unwrap();
if window.is_pending_fullscreen() {
// Remove the real fullscreen.
for ws in self.workspaces_mut() {
if ws.has_window(id) {
ws.set_fullscreen(id, false);
break;
}
}
}
// This will switch is_pending_fullscreen() to false right away.
self.with_windows_mut(|window, _| {
if window.id() == id {
window.request_windowed_fullscreen(!window.is_pending_windowed_fullscreen());
}
});
}
pub fn workspace_switch_gesture_begin(&mut self, output: &Output, is_touchpad: bool) {
let monitors = match &mut self.monitor_set {
MonitorSet::Normal { monitors, .. } => monitors,

View File

@@ -29,6 +29,8 @@ struct TestWindowInner {
pending_fullscreen: Cell<bool>,
pending_activated: Cell<bool>,
is_fullscreen: Cell<bool>,
is_windowed_fullscreen: Cell<bool>,
is_pending_windowed_fullscreen: Cell<bool>,
}
#[derive(Debug, Clone)]
@@ -72,6 +74,8 @@ impl TestWindow {
pending_fullscreen: Cell::new(false),
pending_activated: Cell::new(false),
is_fullscreen: Cell::new(false),
is_windowed_fullscreen: Cell::new(false),
is_pending_windowed_fullscreen: Cell::new(false),
}))
}
@@ -101,6 +105,13 @@ impl TestWindow {
changed = true;
}
if self.0.is_windowed_fullscreen.get() != self.0.is_pending_windowed_fullscreen.get() {
self.0
.is_windowed_fullscreen
.set(self.0.is_pending_windowed_fullscreen.get());
changed = true;
}
changed
}
}
@@ -144,6 +155,10 @@ impl LayoutElement for TestWindow {
) {
self.0.requested_size.set(Some(size));
self.0.pending_fullscreen.set(is_fullscreen);
if is_fullscreen {
self.0.is_pending_windowed_fullscreen.set(false);
}
}
fn min_size(&self) -> Size<i32, Logical> {
@@ -191,10 +206,18 @@ impl LayoutElement for TestWindow {
fn set_floating(&mut self, _floating: bool) {}
fn is_fullscreen(&self) -> bool {
if self.0.is_windowed_fullscreen.get() {
return false;
}
self.0.is_fullscreen.get()
}
fn is_pending_fullscreen(&self) -> bool {
if self.0.is_pending_windowed_fullscreen.get() {
return false;
}
self.0.pending_fullscreen.get()
}
@@ -202,6 +225,14 @@ impl LayoutElement for TestWindow {
self.0.requested_size.get()
}
fn is_pending_windowed_fullscreen(&self) -> bool {
self.0.is_pending_windowed_fullscreen.get()
}
fn request_windowed_fullscreen(&mut self, value: bool) {
self.0.is_pending_windowed_fullscreen.set(value);
}
fn is_child_of(&self, parent: &Self) -> bool {
self.0.parent_id.get() == Some(parent.0.id)
}
@@ -374,6 +405,7 @@ enum Op {
window: usize,
is_fullscreen: bool,
},
ToggleWindowedFullscreen(#[proptest(strategy = "1..=5usize")] usize),
FocusColumnLeft,
FocusColumnRight,
FocusColumnFirst,
@@ -901,14 +933,26 @@ impl Op {
layout.remove_window(&id, Transaction::new());
}
Op::FullscreenWindow(id) => {
if !layout.has_window(&id) {
return;
}
layout.toggle_fullscreen(&id);
}
Op::SetFullscreenWindow {
window,
is_fullscreen,
} => {
if !layout.has_window(&window) {
return;
}
layout.set_fullscreen(&window, is_fullscreen);
}
Op::ToggleWindowedFullscreen(id) => {
if !layout.has_window(&id) {
return;
}
layout.toggle_windowed_fullscreen(&id);
}
Op::FocusColumnLeft => layout.focus_left(),
Op::FocusColumnRight => layout.focus_right(),
Op::FocusColumnFirst => layout.focus_column_first(),
@@ -3187,6 +3231,37 @@ fn unfullscreen_with_large_border() {
check_ops_with_options(options, &ops);
}
#[test]
fn fullscreen_to_windowed_fullscreen() {
let ops = [
Op::AddOutput(0),
Op::AddWindow {
params: TestWindowParams::new(0),
},
Op::FullscreenWindow(0),
Op::Communicate(0), // Make sure it goes into fullscreen.
Op::ToggleWindowedFullscreen(0),
];
check_ops(&ops);
}
#[test]
fn windowed_fullscreen_to_fullscreen() {
let ops = [
Op::AddOutput(0),
Op::AddWindow {
params: TestWindowParams::new(0),
},
Op::FullscreenWindow(0),
Op::Communicate(0), // Commit fullscreen state.
Op::ToggleWindowedFullscreen(0), // Switch is_fullscreen() to false.
Op::FullscreenWindow(0), // Switch is_fullscreen() back to true.
];
check_ops(&ops);
}
fn parent_id_causes_loop(layout: &Layout<TestWindow>, id: usize, mut parent_id: usize) -> bool {
if parent_id == id {
return true;

View File

@@ -120,6 +120,36 @@ pub struct Mapped {
///
/// Used for double-resize-click tracking.
last_interactive_resize_start: Cell<Option<(Duration, ResizeEdge)>>,
/// Whether this window is in windowed (fake) fullscreen.
///
/// In this mode, the underlying window is told that it's fullscreen, while keeping it as
/// a regular, non-fullscreen tile.
is_windowed_fullscreen: bool,
/// Whether this window is pending to go to windowed (fake) fullscreen.
///
/// Several places in the layout code assume that is_fullscreen() can flip only on a commit.
/// Which is something that we do want to flip when changing is_windowed_fullscreen. Flipping
/// it right away would mean remembering to call layout.update_window() after any operation
/// that may change is_windowed_fullscreen, which is quite tricky and error-prone, especially
/// for deeply nested operations.
///
/// It's also not clear what's the best way to go about it. Ideally we'd wait for configure ack
/// and commit before "committing" to is_windowed_fullscreen, however, since it's not real
/// Wayland state, we may end up with no Wayland state change to configure at all.
///
/// For example: when the window is in real fullscreen, but its non-fullscreen size matches
/// its fullscreen size. Then turning on is_windowed_fullscreen will both keep the
/// fullscreen state, and keep the size (since it matches), resulting in no configure.
///
/// So we work around this by "committing" is_pending_windowed_fullscreen to
/// is_windowed_fullscreen on receiving any actual window commit, and whenever
/// is_pending_windowed_fullscreen changes, we mark the window as needs_configure. This does
/// mean some unnecessary delays in some cases, but it also means being able to better
/// synchronize our windowed fullscreen state to the real window updates, so that's good I
/// guess.
is_pending_windowed_fullscreen: bool,
}
niri_render_elements! {
@@ -209,6 +239,8 @@ impl Mapped {
pending_transactions: Vec::new(),
interactive_resize: None,
last_interactive_resize_start: Cell::new(None),
is_windowed_fullscreen: false,
is_pending_windowed_fullscreen: false,
}
}
@@ -608,10 +640,21 @@ impl LayoutElement for Mapped {
animate: bool,
transaction: Option<Transaction>,
) {
// Going into real fullscreen resets windowed fullscreen.
if is_fullscreen {
self.is_pending_windowed_fullscreen = false;
if self.is_windowed_fullscreen {
// Make sure we receive a commit to update self.is_windowed_fullscreen to false
// later on.
self.needs_configure = true;
}
}
let changed = self.toplevel().with_pending_state(|state| {
let changed = state.size != Some(size);
state.size = Some(size);
if is_fullscreen {
if is_fullscreen || self.is_pending_windowed_fullscreen {
state.states.set(xdg_toplevel::State::Fullscreen);
} else {
state.states.unset(xdg_toplevel::State::Fullscreen);
@@ -660,7 +703,8 @@ impl LayoutElement for Mapped {
let same_size = last_sent.size.unwrap_or_default() == size;
let has_fullscreen = last_sent.states.contains(xdg_toplevel::State::Fullscreen);
(same_size && !has_fullscreen).then_some(last_serial)
let same_fullscreen = has_fullscreen == self.is_pending_windowed_fullscreen;
(same_size && same_fullscreen).then_some(last_serial)
});
if let Some(serial) = already_sent {
@@ -687,7 +731,9 @@ impl LayoutElement for Mapped {
let changed = self.toplevel().with_pending_state(|state| {
let changed = state.size != Some(size);
state.size = Some(size);
state.states.unset(xdg_toplevel::State::Fullscreen);
if !self.is_pending_windowed_fullscreen {
state.states.unset(xdg_toplevel::State::Fullscreen);
}
changed
});
@@ -934,6 +980,10 @@ impl LayoutElement for Mapped {
}
fn is_fullscreen(&self) -> bool {
if self.is_windowed_fullscreen {
return false;
}
with_toplevel_role(self.toplevel(), |role| {
role.current
.states
@@ -942,6 +992,10 @@ impl LayoutElement for Mapped {
}
fn is_pending_fullscreen(&self) -> bool {
if self.is_pending_windowed_fullscreen {
return false;
}
self.toplevel()
.with_pending_state(|state| state.states.contains(xdg_toplevel::State::Fullscreen))
}
@@ -1014,7 +1068,7 @@ impl LayoutElement for Mapped {
if let Some((mut size, fullscreen)) = pending {
// If the pending change is fullscreen, we can't use that size.
if fullscreen {
if fullscreen && !self.is_pending_windowed_fullscreen {
return None;
}
@@ -1034,6 +1088,33 @@ impl LayoutElement for Mapped {
}
}
fn is_pending_windowed_fullscreen(&self) -> bool {
self.is_pending_windowed_fullscreen
}
fn request_windowed_fullscreen(&mut self, value: bool) {
if self.is_pending_windowed_fullscreen == value {
return;
}
self.is_pending_windowed_fullscreen = value;
// Set the fullscreen state to match.
//
// When going from windowed to real fullscreen, we'll use request_size() which will set the
// fullscreen state back.
self.toplevel().with_pending_state(|state| {
if value {
state.states.set(xdg_toplevel::State::Fullscreen);
} else {
state.states.unset(xdg_toplevel::State::Fullscreen);
}
});
// Make sure we recieve a commit later to update self.is_windowed_fullscreen.
self.needs_configure = true;
}
fn is_child_of(&self, parent: &Self) -> bool {
self.toplevel().parent().as_ref() == Some(parent.toplevel().wl_surface())
}
@@ -1098,5 +1179,12 @@ impl LayoutElement for Mapped {
self.request_size_once = Some(RequestSizeOnce::UseWindowSize);
}
}
// HACK: this is not really accurate because the commit might be for an earlier serial than
// when we requested windowed fullscren. But we don't actually care much, since this is
// entirely compositor state. We're only tying it to configure/commit as a workaround to
// the rest of the code expecting that fullscreen doesn't suddenly just change in the
// middle of something.
self.is_windowed_fullscreen = self.is_pending_windowed_fullscreen;
}
}