aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorIvan Molodetskikh <yalterz@gmail.com>2025-03-17 14:56:29 +0300
committerIvan Molodetskikh <yalterz@gmail.com>2025-03-17 22:31:19 -0700
commit39f52b75856936db01325aa1c7f15fe8f379485d (patch)
tree08fcbf8c938aa2b10a9be3014bf292f17225063d /src
parentb447b1f4de65ee706bdbbdb9b650ab030459c0fb (diff)
downloadniri-39f52b75856936db01325aa1c7f15fe8f379485d.tar.gz
niri-39f52b75856936db01325aa1c7f15fe8f379485d.tar.bz2
niri-39f52b75856936db01325aa1c7f15fe8f379485d.zip
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.
Diffstat (limited to 'src')
-rw-r--r--src/input/mod.rs17
-rw-r--r--src/layout/mod.rs41
-rw-r--r--src/layout/tests.rs75
-rw-r--r--src/window/mapped.rs96
4 files changed, 225 insertions, 4 deletions
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 08bebe40..c9677ba2 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -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());
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 15d48d6a..64e6877c 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -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,
diff --git a/src/layout/tests.rs b/src/layout/tests.rs
index 266b615e..8e1c3adf 100644
--- a/src/layout/tests.rs
+++ b/src/layout/tests.rs
@@ -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;
diff --git a/src/window/mapped.rs b/src/window/mapped.rs
index 9c4670dd..29dc185a 100644
--- a/src/window/mapped.rs
+++ b/src/window/mapped.rs
@@ -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;
}
}