Swayfx Version
swayfx version 0.6 (based on sway 1.12.0)
The affected code is byte-identical on master (8775477): git diff 0.6 origin/master -- sway/desktop/transaction.c is empty. 0.5.3 is not affected: the container geometry animation landed in 0b7dfdb (#388, "feat: add resize / movement animations") and first shipped in 0.6.
- scenefx 0.5, wlroots 0.20.2
- NixOS 26.11, Intel Arc 140V, open source driver (no proprietary/nvidia driver involved)
- Observed on real hardware (eDP-1 2880x1800 + DP-3) and reproduced deterministically in a nested headless instance
Description
Unplug an external screen while a workspace on it is not the one you are looking at. Sway moves that workspace to a surviving output and resizes it correctly - swaymsg -t get_tree reports the new geometry immediately. The scene is never told, though. The first frame after you switch to that workspace still draws its windows at the size and position they had on the output that is gone, and then animates them into place.
The same thing happens with no hotplug at all: change an output's mode while one of its workspaces is hidden, switch back to it, and it animates in from the old resolution.
Triggers, in decreasing order of how likely you are to hit them:
- an output disappears (unplug, lid close,
output <name> disable) and its workspaces are evacuated to another output;
- an output's mode or scale changes while one of its workspaces is hidden;
- in principle anything else that resizes a hidden workspace, such as a layer surface changing the usable area. I have only measured the first two.
Floating windows go through the same arrange_container() via arrange_workspace_floating(), so they are affected as well, though every measurement below is of the tiled case. The tree is correct throughout; only the picture is one hotplug behind.
Workaround for anyone who hits this: animation_duration_ms 0. arrange_container() returns before any of the animation code in that case.
Reproduction
Manual, on any two-monitor setup with animation_duration_ms above 0:
- On the external screen, open a couple of windows on a workspace, say 5.
- Focus the internal screen and switch it to some other workspace, so workspace 5 is not visible anywhere.
- Unplug the external screen. Workspace 5 moves to the internal one.
swaymsg -t get_tree already reports the internal screen's geometry for it.
- Switch to workspace 5. It animates from the external screen's size and position into the internal screen's.
Scripted, self-contained, no hardware needed (full scripts at the bottom). Both run a nested headless instance with two tiled windows - green on the left, blue on the right - and sample the framebuffer with grim half a second after the workspace is shown. Both windows are half the output, so a correct frame is 50/50. animation_duration_ms is set to 3000 only so the frame can be sampled while the animation is still running. Needs foot, grim, jq, python3:
$ SWAY=./build/sway ./repro-unplug-ws-resize.sh
workspace 5 on HEADLESS-2 (1280x720): green 50.0%, blue 50.0%
after unplug, workspace 5 is on HEADLESS-1, tree geometry: 200x300 200x300
switch to workspace 5, +0.5s: green 43.4%, blue 0.0%
switch to workspace 5, +4.5s: green 50.0%, blue 50.0%
FAIL: at +0.5s the workspace is still drawn with the unplugged
output's geometry - the right-hand window is off screen
$ SWAY=./build/sway ./repro-mode-change-hidden.sh
workspace 1 at 800x600: green 50.0%, blue 50.0%
output is now 400x300, workspace 1 tree geometry: 200x300 200x300
switch back to workspace 1, +0.5s: green 77.7%, blue 22.2%
switch back to workspace 1, +4.5s: green 50.0%, blue 50.0%
FAIL: at +0.5s the workspace is still drawn at the old resolution
Note the tree geometry line in both: sway has already resized the workspace to 200x300 per window before anything is drawn.
Each pane below is the surviving output half a second after the workspace is shown, 0.6 on the left and the patch on the right. The frames are dim because the workspace fade is running at the same time.
Top row, the unplug case: on 0.6 the left window has entered from x≈232 and the right one is still entirely off screen, because both are animating from where they sat on the 1280-wide output that was removed. Bottom row, the mode change: the split is at 78% instead of 50%.
Debug Log
Nothing in sway -d is relevant. Animation state is never logged, and the transaction/arrange path logs identically on an affected and an unaffected switch. Both reproducer scripts write a full log next to their screenshots, and I am happy to attach one if you want to see it.
Configuration File
Not reproducible with the stock defaults, since animation_duration_ms defaults to 0. This is the minimum:
default_border none
gaps inner 0
gaps outer 0
animation_duration_ms 3000
output HEADLESS-1 pos 0 0 res 400x300 bg #000000 solid_color
output HEADLESS-2 pos 400 0 res 1280x720 bg #000000 solid_color
Only animation_duration_ms above 0 is load-bearing. The rest just makes the screenshots unambiguous.
Root cause
The tree is reallocated when the output goes away. output_disable() (sway/tree/output.c:284) calls output_evacuate(), which moves every workspace to a surviving output, and the modeset that follows ends in arrange_root() (sway/config/output.c:1100). That walks every workspace of every output, hidden ones included (arrange_output(), sway/tree/arrange.c:325), so workspace->width/height and each container's current box are already correct before the switch. This is what the reproducers' "tree geometry" line shows.
The scene is not. arrange_output() (sway/desktop/transaction.c:886) arranges only the workspace that is on screen:
if (activated) {
...
arrange_workspace_tiling(child, ...);
arrange_workspace_floating(child);
} else if (animating && !activated) {
// Workspace fading out - keep visible, alpha handled by render path
...
} else {
wlr_scene_node_set_enabled(&child->layers.tiling->node, false);
wlr_scene_node_set_enabled(&child->layers.fullscreen->node, false);
disable_workspace(child);
}
That comes from the sway rebases (82fe097, f47f35c) and is fine in sway, where the first arrange after a workspace is shown simply applies the new sizes in one frame.
arrange_container() (sway/desktop/transaction.c:730) is what makes it visible here. It turns any change of target geometry into an animation whose start state is the last painted geometry:
} else {
// move animation
snap_animation_position(con);
con->animation_state.from_width = con->animation_state.current_width;
con->animation_state.from_height = con->animation_state.current_height;
current_width / current_height and current_global_x / current_global_y were written by the last _arrange_container() (sway/desktop/transaction.c:530). For a hidden workspace that is the arrange which ran on the output it has since left, so the size comes from the removed output. snap_animation_position() (sway/desktop/transaction.c:139) then converts that stored absolute position into the new parent's coordinates, which is why the windows also slide in from where they used to sit: in the headless run the left window starts at x≈232 on a 400px output and the right one starts past its right edge.
So the two halves disagree about who owns the geometry of a hidden workspace. The tree updates it eagerly, the scene updates it lazily on the frame the workspace is shown, and the animation code treats that lazy update as if the user had just moved something.
Suggested fix
A workspace that was off screen on the previous frame has no "from" geometry worth animating: whatever its scene nodes hold is stale by definition. Snap it into place and let the existing workspace fade do the rest.
--- a/sway/desktop/transaction.c
+++ b/sway/desktop/transaction.c
@@ -727,6 +727,11 @@ static void _arrange_container(struct sway_container *con,
}
}
+// Set while arranging a workspace that was off screen on the previous frame.
+// Such a workspace has no meaningful "from" geometry to animate out of: see
+// the comment in arrange_output().
+static bool arrange_from_offscreen = false;
+
static void arrange_container(struct sway_container *con,
int width, int height, int x, int y, bool title_bar, int gaps) {
if (!config->animation_duration_ms || !con->view
@@ -763,6 +768,17 @@ static void arrange_container(struct sway_container *con,
con->animation_state.from_height = height * POPIN_FACTOR;
con->animation_state.from_alpha = 0.0f;
add_animation(&con->animation_state.animation, anim_update_callback, NULL);
+ } else if (arrange_from_offscreen) {
+ // The container's scene geometry is whatever it was when the workspace
+ // was last on screen, so there is nothing to animate from. Snap.
+ finish_animation(&con->animation_state.animation);
+ con->animation_state.from_x = x;
+ con->animation_state.from_y = y;
+ con->animation_state.from_width = width;
+ con->animation_state.from_height = height;
+ con->animation_state.from_alpha = 1.0f;
+ _arrange_container(con, width, height, x, y, title_bar, gaps);
+ return;
} else {
// move animation
snap_animation_position(con);
@@ -891,6 +907,21 @@ static void arrange_output(struct sway_output *output, int width, int height) {
&& output->wlr_output->enabled && config->animation_duration_ms > 0 &&
!(old_active->current.fullscreen || new_active->current.fullscreen);
+ // arrange_output only arranges the workspace that is on screen, so a
+ // hidden one keeps the scene geometry it had when it was last shown -
+ // including the size of an output it no longer lives on, since
+ // output_disable() evacuates workspaces to a surviving output and only the
+ // tree-level arrange_root() follows. Animating from that stale geometry
+ // makes the workspace resize itself in front of the user on the first
+ // frame after the switch. It is not a move the user performed, so there is
+ // nothing to animate: snap to the geometry the tree already agreed on and
+ // let the fade below do the rest. Sampled here because the fade
+ // initializes new_active's animation, which is also how a workspace that
+ // is still fading out - and therefore does have valid scene geometry -
+ // is told apart from one that was off screen.
+ bool new_active_offscreen = new_active && new_active != old_active
+ && !new_active->animation_state.animation.initialized;
+
if (is_ws_switch) {
new_active->animation_state.from_alpha = 0.0f;
@@ -933,6 +964,9 @@ static void arrange_output(struct sway_output *output, int width, int height) {
}
if (activated) {
+ // child is new_active here; see new_active_offscreen above.
+ arrange_from_offscreen = new_active_offscreen;
+
struct sway_container *fs = child->current.fullscreen;
wlr_scene_node_set_enabled(&child->layers.tiling->node, !fs);
wlr_scene_node_set_enabled(&child->layers.fullscreen->node, fs);
@@ -962,6 +996,8 @@ static void arrange_output(struct sway_output *output, int width, int height) {
area->height - gaps->top - gaps->bottom);
arrange_workspace_floating(child);
}
+
+ arrange_from_offscreen = false;
} else if (animating && !activated) {
// Workspace fading out - keep visible, alpha handled by render path
struct wlr_box *area = &output->usable_area;
Four notes on the patch:
- Why the flag is sampled where it is. The
is_ws_switch block calls add_animation() on new_active, so by the time the workspace loop runs, animating is true for the workspace being activated and cannot be used to ask "was this on screen". Reading animation_state.animation.initialized before that block answers it: initialized there means the workspace is still fading out from an earlier switch, which is the one case where a hidden-looking workspace does have valid scene geometry.
- Why a file-scope flag.
arrange_container() is reached from arrange_workspace_tiling(), arrange_workspace_floating() and arrange_fullscreen(), and recursively through _arrange_container() -> arrange_children(). Threading a parameter means touching all of them for one bool. Happy to write it that way instead if you prefer an explicit parameter.
- Open animations are unaffected. The
from_x == -1 branch is checked first, so a window opened on a hidden workspace still pops in when you switch to it.
- The other direction was considered and dropped. Arranging hidden workspaces in the scene when their geometry changes - doing the reallocation at hotplug time - undoes the "only arrange what is on screen" behaviour inherited from sway and costs a scene walk per hidden workspace per transaction, for a case that only matters at the moment the workspace comes back.
Test plan
Built from the 0.6 tag with only this patch applied (scenefx 0.5, wlroots 0.20.2). Percentages are of the framebuffer, sampled with grim, not judged by eye. Both windows settle at 50/50, so anything else on the first row is the bug.
| Case |
0.6 |
Patched |
| Unplug the output a hidden workspace lives on, then switch to it |
green 43.4%, blue 0.0% |
green 50.0%, blue 50.0% |
Same via output <name> disable |
green 42.7%, blue 1.6% |
green 50.0%, blue 50.0% |
| Change an output's mode while a workspace there is hidden, then switch back |
green 77.7%, blue 22.2% |
green 50.0%, blue 50.0% |
| Opening a second window resizes the first, animated |
animates, 53.3% -> 49.4% |
animates, 51.9% -> 49.4% |
| Workspace switch away and back with nothing changed |
50/50 at +1s and settled |
50/50 at +1s and settled |
| Two windows on a single output, no hotplug, no mode change |
unchanged |
unchanged |
The fourth and fifth rows are the regressions that matter: the resize animation still runs when it should, and an ordinary workspace switch still shows the settled geometry with no bleed-through from the other workspace. Both were measured with a third script, on both builds, and both builds agree.
With animation_duration_ms 0, arrange_container() returns at its first branch, so the patch is a no-op.
Related
I am running this patch locally and can open a PR with it if you would like it in that form.
Reproducer 1 (repro-unplug-ws-resize.sh) - the hotplug case
#!/bin/sh
# Reproducer for: a workspace evacuated from an unplugged output keeps that
# output's scene geometry, and animates down to its new size the first time it
# is shown, instead of being resized when the output went away.
#
# Runs a nested headless swayfx with a small output (stays) and a large one
# (unplugged). A workspace on the large output holds two tiled windows, green
# on the left and blue on the right. After the unplug the workspace belongs to
# the small output, where the tree gives each window half of 400px. Switching
# to it should therefore show green and blue at 50/50 straight away.
#
# On 0.6 the first frames instead draw both windows at the unplugged output's
# 640px, so the blue one starts off screen and the frame is all green.
#
# MODE=disable runs the same thing through `output <name> disable` instead of
# the hotplug path; both end up in output_disable() -> output_evacuate().
#
# Requires: the swayfx build under test, swaymsg, foot, grim, jq, python3.
# SWAY=/path/to/sway ./repro-unplug-ws-resize.sh
set -eu
SWAY=${SWAY:-sway}
SWAYMSG=${SWAYMSG:-$(dirname "$(command -v "$SWAY")")/swaymsg}
DIR=${DIR:-$(mktemp -d)}
: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}"
export XDG_RUNTIME_DIR
# animation_duration_ms is deliberately long: it is the only way to sample a
# frame while the geometry animation is still running. Any value above ~500
# shows the same thing, this one just makes the timing forgiving.
cat > "$DIR/config" <<EOF
default_border none
gaps inner 0
gaps outer 0
animation_duration_ms 3000
output HEADLESS-1 pos 0 0 res 400x300 bg #000000 solid_color
output HEADLESS-2 pos 400 0 res 1280x720 bg #000000 solid_color
EOF
before=$(ls "$XDG_RUNTIME_DIR"/wayland-* 2>/dev/null | tr '\n' ' ' || true)
unset WAYLAND_DISPLAY DISPLAY SWAYSOCK
WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=2 \
"$SWAY" -d -c "$DIR/config" > "$DIR/sway.log" 2>&1 &
pid=$!
trap 'kill $pid 2>/dev/null || true' EXIT
sleep 3
for f in "$XDG_RUNTIME_DIR"/wayland-*; do
case "$f" in *.lock) continue;; esac
case " $before " in *" $f "*) continue;; esac
WAYLAND_DISPLAY=$(basename "$f")
done
export WAYLAND_DISPLAY
SWAYSOCK="$XDG_RUNTIME_DIR/sway-ipc.$(id -u).$pid.sock"
export SWAYSOCK
m() { "$SWAYMSG" "$@" > /dev/null; }
# $1 = output -> "<green%> <blue%>" of the framebuffer
sample() {
grim -t ppm -o "$1" - | python3 -c '
import sys
d = sys.stdin.buffer.read()
tok, i = [], 2
while len(tok) < 3:
while d[i:i+1].isspace(): i += 1
if d[i:i+1] == b"#":
while d[i:i+1] != b"\n": i += 1
continue
j = i
while not d[j:j+1].isspace(): j += 1
tok.append(int(d[i:j])); i = j
px = d[i+1:]
n = len(px) // 3
# The workspace fade runs at the same time as the geometry animation, so both
# colours arrive dimmed. Classify by which channel dominates, not by value.
g = sum(1 for k in range(0, n*3, 3) if px[k+1] > 20 and px[k+1] > px[k+2])
b = sum(1 for k in range(0, n*3, 3) if px[k+2] > 20 and px[k+2] > px[k+1])
print(f"{100*g/n:.1f} {100*b/n:.1f}")
'
}
m focus output HEADLESS-2
m workspace 5
foot -o colors.background=00ff00 sh -c 'clear; sleep 3600' 2>/dev/null &
sleep 3
foot -o colors.background=0000ff sh -c 'clear; sleep 3600' 2>/dev/null &
sleep 3
printf 'workspace 5 on HEADLESS-2 (1280x720): green %s%%, blue %s%%\n' $(sample HEADLESS-2)
# Focus the surviving output first, so the evacuated workspace lands there
# hidden rather than being activated by the unplug itself.
m focus output HEADLESS-1
m workspace 1
m output HEADLESS-2 "${MODE:-unplug}"
sleep 2
geom=$("$SWAYMSG" -t get_tree | jq -r '
.. | objects | select(.name? == "5") | .nodes[] | "\(.rect.width)x\(.rect.height)"' | tr '\n' ' ')
printf 'after %s, workspace 5 is on %s, tree geometry: %s\n' "${MODE:-unplug}" \
"$("$SWAYMSG" -t get_workspaces | jq -r '.[] | select(.name=="5") | .output')" "$geom"
m workspace 5
sleep 0.5
mid=$(sample HEADLESS-1)
grim -o HEADLESS-1 "$DIR/switch+0.5s.png"
sleep 4
settled=$(sample HEADLESS-1)
grim -o HEADLESS-1 "$DIR/switch+4.5s.png"
printf 'switch to workspace 5, +0.5s: green %s%%, blue %s%%\n' $mid
printf 'switch to workspace 5, +4.5s: green %s%%, blue %s%%\n' $settled
m exit 2>/dev/null || true
# Both windows should be half the output as soon as the workspace is shown.
set -- $mid
if [ "${2%%.*}" -lt 40 ]; then
printf '\nFAIL: at +0.5s the workspace is still drawn with the unplugged\n'
printf ' output'"'"'s geometry - the right-hand window is off screen\n'
printf 'screenshots: %s\n' "$DIR"
exit 1
fi
printf '\nPASS: the workspace is drawn at its new size immediately\n'
printf 'screenshots: %s\n' "$DIR"
exit 0
Reproducer 2 (repro-mode-change-hidden.sh) - same bug with no hotplug involved
#!/bin/sh
# Same class as repro-unplug-ws-resize.sh, without any hotplug: change an
# output's mode while one of its workspaces is hidden. The hidden workspace is
# never re-arranged in the scene either, so switching to it animates from the
# old resolution.
#
# Single 800x600 output, workspace 1 holds a green and a blue window at 400px
# each. Switch away, drop the output to 400x300, switch back: each window
# should be 200px straight away.
#
# Requires: the swayfx build under test, swaymsg, foot, grim, jq, python3.
# SWAY=/path/to/sway ./repro-mode-change-hidden.sh
set -eu
SWAY=${SWAY:-sway}
SWAYMSG=${SWAYMSG:-$(dirname "$(command -v "$SWAY")")/swaymsg}
DIR=${DIR:-$(mktemp -d)}
: "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}"
export XDG_RUNTIME_DIR
cat > "$DIR/config" <<EOF
default_border none
gaps inner 0
gaps outer 0
animation_duration_ms 3000
output HEADLESS-1 pos 0 0 res 800x600 bg #000000 solid_color
EOF
before=$(ls "$XDG_RUNTIME_DIR"/wayland-* 2>/dev/null | tr '\n' ' ' || true)
unset WAYLAND_DISPLAY DISPLAY SWAYSOCK
WLR_BACKENDS=headless WLR_HEADLESS_OUTPUTS=1 \
"$SWAY" -d -c "$DIR/config" > "$DIR/sway.log" 2>&1 &
pid=$!
trap 'kill $pid 2>/dev/null || true' EXIT
sleep 3
for f in "$XDG_RUNTIME_DIR"/wayland-*; do
case "$f" in *.lock) continue;; esac
case " $before " in *" $f "*) continue;; esac
WAYLAND_DISPLAY=$(basename "$f")
done
export WAYLAND_DISPLAY
SWAYSOCK="$XDG_RUNTIME_DIR/sway-ipc.$(id -u).$pid.sock"
export SWAYSOCK
m() { "$SWAYMSG" "$@" > /dev/null; }
sample() {
grim -t ppm -o HEADLESS-1 - | python3 -c '
import sys
d = sys.stdin.buffer.read()
tok, i = [], 2
while len(tok) < 3:
while d[i:i+1].isspace(): i += 1
if d[i:i+1] == b"#":
while d[i:i+1] != b"\n": i += 1
continue
j = i
while not d[j:j+1].isspace(): j += 1
tok.append(int(d[i:j])); i = j
px = d[i+1:]
n = len(px) // 3
g = sum(1 for k in range(0, n*3, 3) if px[k+1] > 20 and px[k+1] > px[k+2])
b = sum(1 for k in range(0, n*3, 3) if px[k+2] > 20 and px[k+2] > px[k+1])
print(f"{100*g/n:.1f} {100*b/n:.1f}")
'
}
m workspace 1
foot -o colors.background=00ff00 sh -c 'clear; sleep 3600' 2>/dev/null &
sleep 3
foot -o colors.background=0000ff sh -c 'clear; sleep 3600' 2>/dev/null &
sleep 4
printf 'workspace 1 at 800x600: green %s%%, blue %s%%\n' $(sample)
m workspace 2
sleep 4
m output HEADLESS-1 res 400x300
sleep 3
printf 'output is now %s, workspace 1 tree geometry: %s\n' \
"$("$SWAYMSG" -t get_outputs | jq -r '.[0] | "\(.current_mode.width)x\(.current_mode.height)"')" \
"$("$SWAYMSG" -t get_tree | jq -r '.. | objects | select(.name? == "1") | .nodes[] | "\(.rect.width)x\(.rect.height)"' | tr '\n' ' ')"
m workspace 1
sleep 0.5
mid=$(sample)
grim -o HEADLESS-1 "$DIR/switch+0.5s.png"
sleep 4
settled=$(sample)
printf 'switch back to workspace 1, +0.5s: green %s%%, blue %s%%\n' $mid
printf 'switch back to workspace 1, +4.5s: green %s%%, blue %s%%\n' $settled
m exit 2>/dev/null || true
set -- $mid
if [ "${2%%.*}" -lt 40 ]; then
printf '\nFAIL: at +0.5s the workspace is still drawn at the old resolution\n'
printf 'screenshots: %s\n' "$DIR"
exit 1
fi
printf '\nPASS: the workspace is drawn at the new resolution immediately\n'
printf 'screenshots: %s\n' "$DIR"
exit 0
Swayfx Version
swayfx version 0.6 (based on sway 1.12.0)The affected code is byte-identical on
master(8775477):git diff 0.6 origin/master -- sway/desktop/transaction.cis empty. 0.5.3 is not affected: the container geometry animation landed in 0b7dfdb (#388, "feat: add resize / movement animations") and first shipped in 0.6.Description
Unplug an external screen while a workspace on it is not the one you are looking at. Sway moves that workspace to a surviving output and resizes it correctly -
swaymsg -t get_treereports the new geometry immediately. The scene is never told, though. The first frame after you switch to that workspace still draws its windows at the size and position they had on the output that is gone, and then animates them into place.The same thing happens with no hotplug at all: change an output's mode while one of its workspaces is hidden, switch back to it, and it animates in from the old resolution.
Triggers, in decreasing order of how likely you are to hit them:
output <name> disable) and its workspaces are evacuated to another output;Floating windows go through the same
arrange_container()viaarrange_workspace_floating(), so they are affected as well, though every measurement below is of the tiled case. The tree is correct throughout; only the picture is one hotplug behind.Workaround for anyone who hits this:
animation_duration_ms 0.arrange_container()returns before any of the animation code in that case.Reproduction
Manual, on any two-monitor setup with
animation_duration_msabove 0:swaymsg -t get_treealready reports the internal screen's geometry for it.Scripted, self-contained, no hardware needed (full scripts at the bottom). Both run a nested headless instance with two tiled windows - green on the left, blue on the right - and sample the framebuffer with
grimhalf a second after the workspace is shown. Both windows are half the output, so a correct frame is 50/50.animation_duration_msis set to 3000 only so the frame can be sampled while the animation is still running. Needsfoot,grim,jq,python3:Note the tree geometry line in both: sway has already resized the workspace to 200x300 per window before anything is drawn.
Each pane below is the surviving output half a second after the workspace is shown, 0.6 on the left and the patch on the right. The frames are dim because the workspace fade is running at the same time.
Top row, the unplug case: on 0.6 the left window has entered from x≈232 and the right one is still entirely off screen, because both are animating from where they sat on the 1280-wide output that was removed. Bottom row, the mode change: the split is at 78% instead of 50%.
Debug Log
Nothing in
sway -dis relevant. Animation state is never logged, and the transaction/arrange path logs identically on an affected and an unaffected switch. Both reproducer scripts write a full log next to their screenshots, and I am happy to attach one if you want to see it.Configuration File
Not reproducible with the stock defaults, since
animation_duration_msdefaults to 0. This is the minimum:Only
animation_duration_msabove 0 is load-bearing. The rest just makes the screenshots unambiguous.Root cause
The tree is reallocated when the output goes away.
output_disable()(sway/tree/output.c:284) callsoutput_evacuate(), which moves every workspace to a surviving output, and the modeset that follows ends inarrange_root()(sway/config/output.c:1100). That walks every workspace of every output, hidden ones included (arrange_output(),sway/tree/arrange.c:325), soworkspace->width/heightand each container'scurrentbox are already correct before the switch. This is what the reproducers' "tree geometry" line shows.The scene is not.
arrange_output()(sway/desktop/transaction.c:886) arranges only the workspace that is on screen:That comes from the sway rebases (82fe097, f47f35c) and is fine in sway, where the first arrange after a workspace is shown simply applies the new sizes in one frame.
arrange_container()(sway/desktop/transaction.c:730) is what makes it visible here. It turns any change of target geometry into an animation whose start state is the last painted geometry:current_width/current_heightandcurrent_global_x/current_global_ywere written by the last_arrange_container()(sway/desktop/transaction.c:530). For a hidden workspace that is the arrange which ran on the output it has since left, so the size comes from the removed output.snap_animation_position()(sway/desktop/transaction.c:139) then converts that stored absolute position into the new parent's coordinates, which is why the windows also slide in from where they used to sit: in the headless run the left window starts at x≈232 on a 400px output and the right one starts past its right edge.So the two halves disagree about who owns the geometry of a hidden workspace. The tree updates it eagerly, the scene updates it lazily on the frame the workspace is shown, and the animation code treats that lazy update as if the user had just moved something.
Suggested fix
A workspace that was off screen on the previous frame has no "from" geometry worth animating: whatever its scene nodes hold is stale by definition. Snap it into place and let the existing workspace fade do the rest.
Four notes on the patch:
is_ws_switchblock callsadd_animation()onnew_active, so by the time the workspace loop runs,animatingis true for the workspace being activated and cannot be used to ask "was this on screen". Readinganimation_state.animation.initializedbefore that block answers it: initialized there means the workspace is still fading out from an earlier switch, which is the one case where a hidden-looking workspace does have valid scene geometry.arrange_container()is reached fromarrange_workspace_tiling(),arrange_workspace_floating()andarrange_fullscreen(), and recursively through_arrange_container()->arrange_children(). Threading a parameter means touching all of them for one bool. Happy to write it that way instead if you prefer an explicit parameter.from_x == -1branch is checked first, so a window opened on a hidden workspace still pops in when you switch to it.Test plan
Built from the 0.6 tag with only this patch applied (scenefx 0.5, wlroots 0.20.2). Percentages are of the framebuffer, sampled with
grim, not judged by eye. Both windows settle at 50/50, so anything else on the first row is the bug.output <name> disableThe fourth and fifth rows are the regressions that matter: the resize animation still runs when it should, and an ordinary workspace switch still shows the settled geometry with no bleed-through from the other workspace. Both were measured with a third script, on both builds, and both builds agree.
With
animation_duration_ms 0,arrange_container()returns at its first branch, so the patch is a no-op.Related
I am running this patch locally and can open a PR with it if you would like it in that form.
Reproducer 1 (
repro-unplug-ws-resize.sh) - the hotplug caseReproducer 2 (
repro-mode-change-hidden.sh) - same bug with no hotplug involved