egui icon indicating copy to clipboard operation
egui copied to clipboard

Non-resizable window jumps to incorrect size on trying to resize

Open GoldsteinE opened this issue 7 months ago • 6 comments

Describe the bug

I’m running this code:

use eframe::egui::ViewportBuilder;

fn main() {
    let native_options = eframe::NativeOptions {
        viewport: ViewportBuilder::default()
            .with_resizable(false)
            .with_inner_size((200.0, 200.0)),
        ..<_>::default()
    };
    eframe::run_simple_native("scale bug", native_options, |_, _| {}).unwrap();
}

I expect it to produce a fixed-size floating window, which it does. If I then drag a window border for a bit, the size of the window suddenly changes as if multiplied by scale factor (e.g. if I have scale factor 2, window size doubles).

https://github.com/user-attachments/assets/4a932e37-c99f-4b57-9348-a8108c11be0e

To Reproduce Steps to reproduce the behavior:

  1. Run attached code on a scaled Wayland workspace.
  2. Drag the border a bit.

Expected behavior Window remains fixed size as I attempt to resize it.

Desktop

  • OS: NixOS unstable
  • Wayland compositor: sway version 1.11-rc1

Additional context I’m using wgpu backend, since default one doesn’t work for me at all due to https://github.com/emilk/egui/issues/3174. My Cargo.toml looks like this:

[package]
name = "egui-scale-bug"
version = "0.1.0"
edition = "2024"

[dependencies.eframe]
version = "0.31.1"
default-features = false
features = ["accesskit", "default_fonts", "wgpu", "x11", "wayland"]

I additionally tried it with egui from main:

[package]
name = "egui-scale-bug"
version = "0.1.0"
edition = "2024"

[dependencies.eframe]
git = "https://github.com/emilk/egui"
branch = "main"
default-features = false
features = ["accesskit", "default_fonts", "wgpu", "x11", "wayland"]

[dependencies.wgpu]
version = "*"
features = ["vulkan"]

This might be a bug in winit, I’m not yet sure.

GoldsteinE avatar May 27 '25 08:05 GoldsteinE

I’ve experimented a bit more and I don’t think that’s a winit bug.

I’ve adapted wgpu’s “hello window” example to create a non-resizeable fixed-logical-size window:

A long code snippet
use std::sync::Arc;

use winit::{
    application::ApplicationHandler,
    dpi::LogicalSize,
    event::WindowEvent,
    event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
    window::{Window, WindowId},
};

struct State {
    window: Arc<Window>,
    device: wgpu::Device,
    queue: wgpu::Queue,
    size: winit::dpi::PhysicalSize<u32>,
    surface: wgpu::Surface<'static>,
    surface_format: wgpu::TextureFormat,
}

impl State {
    async fn new(window: Arc<Window>) -> State {
        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
        let adapter = instance
            .request_adapter(&wgpu::RequestAdapterOptions::default())
            .await
            .unwrap();
        let (device, queue) = adapter
            .request_device(&wgpu::DeviceDescriptor::default())
            .await
            .unwrap();

        let size = window.inner_size();

        let surface = instance.create_surface(window.clone()).unwrap();
        let cap = surface.get_capabilities(&adapter);
        let surface_format = cap.formats[0];

        let state = State {
            window,
            device,
            queue,
            size,
            surface,
            surface_format,
        };

        // Configure surface for the first time
        state.configure_surface();

        state
    }

    fn get_window(&self) -> &Window {
        &self.window
    }

    fn configure_surface(&self) {
        let surface_config = wgpu::SurfaceConfiguration {
            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
            format: self.surface_format,
            // Request compatibility with the sRGB-format texture view we‘re going to create later.
            view_formats: vec![self.surface_format.add_srgb_suffix()],
            alpha_mode: wgpu::CompositeAlphaMode::Auto,
            width: self.size.width,
            height: self.size.height,
            desired_maximum_frame_latency: 2,
            present_mode: wgpu::PresentMode::AutoVsync,
        };
        self.surface.configure(&self.device, &surface_config);
    }

    fn resize(&mut self, new_size: winit::dpi::PhysicalSize<u32>) {
        self.size = new_size;

        // reconfigure the surface
        self.configure_surface();
    }

    fn render(&mut self) {
        // Create texture view
        let surface_texture = self
            .surface
            .get_current_texture()
            .expect("failed to acquire next swapchain texture");
        let texture_view = surface_texture
            .texture
            .create_view(&wgpu::TextureViewDescriptor {
                // Without add_srgb_suffix() the image we will be working with
                // might not be "gamma correct".
                format: Some(self.surface_format.add_srgb_suffix()),
                ..Default::default()
            });

        // Renders a GREEN screen
        let mut encoder = self.device.create_command_encoder(&Default::default());
        // Create the renderpass which will clear the screen.
        let renderpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: None,
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: &texture_view,
                resolve_target: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Clear(wgpu::Color::GREEN),
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: None,
            timestamp_writes: None,
            occlusion_query_set: None,
        });

        // If you wanted to call any drawing commands, they would go here.

        // End the renderpass.
        drop(renderpass);

        // Submit the command in the queue to execute
        self.queue.submit([encoder.finish()]);
        self.window.pre_present_notify();
        surface_texture.present();
    }
}

#[derive(Default)]
struct App {
    state: Option<State>,
}

impl ApplicationHandler for App {
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        // Create window object
        let window = Arc::new(
            event_loop
                .create_window(
                    Window::default_attributes()
                        .with_resizable(false)
                        .with_inner_size(LogicalSize::new(200.0, 200.0)),
                )
                .unwrap(),
        );

        let state = pollster::block_on(State::new(window.clone()));
        self.state = Some(state);

        window.request_redraw();
    }

    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
        let state = self.state.as_mut().unwrap();
        match event {
            WindowEvent::CloseRequested => {
                println!("The close button was pressed; stopping");
                event_loop.exit();
            }
            WindowEvent::RedrawRequested => {
                state.render();
                // Emits a new redraw requested event.
                state.get_window().request_redraw();
            }
            WindowEvent::Resized(size) => {
                // Reconfigures the size of the surface. We do not re-render
                // here as this event is always followed up by redraw request.
                state.resize(size);
            }
            _ => (),
        }
    }
}

fn main() {
    // wgpu uses `log` for all of our logging, so we initialize a logger with the `env_logger` crate.
    //
    // To change the log level, set the `RUST_LOG` environment variable. See the `env_logger`
    // documentation for more information.
    env_logger::init();

    let event_loop = EventLoop::new().unwrap();

    // When the current loop iteration finishes, immediately begin a new
    // iteration regardless of whether or not new events are available to
    // process. Preferred for applications that want to render as fast as
    // possible, like games.
    event_loop.set_control_flow(ControlFlow::Poll);

    // When the current loop iteration finishes, suspend the thread until
    // another event arrives. Helps keeping CPU utilization low if nothing
    // is happening, which is preferred if the application might be idling in
    // the background.
    // event_loop.set_control_flow(ControlFlow::Wait);

    let mut app = App::default();
    event_loop.run_app(&mut app).unwrap();
}

with corresponding Cargo.toml

[package]
name = "bare-winit-repro"
version = "0.1.0"
edition = "2024"

[dependencies]
env_logger = "0.11.8"
pollster = "0.4.0"
wgpu = { version = "25.0.2", features = ["vulkan"] }
winit = "0.30.11"

and it does not reproduce the issue. I’d guess that something inside eframe code is confusing logical pixels with physical pixels, but I haven’t dived into egui code yet.

GoldsteinE avatar May 27 '25 09:05 GoldsteinE

Adding dbg!(ctx.screen_rect(), ctx.zoom_factor()) into update function reveals that after resize attempt screen_rect size jumps to 400 (zoom_factor is always 1.0). I’ve played a bit with different scale factors:

Scale factor screen_rect after resize attempt
0.5 200
1 200
1.5 400
2 400
2.5 600
3 600

It appears to be multiplied by scale factor rounded up, which seems to rule out simply confusing logical pixels with physical pixels.

GoldsteinE avatar May 27 '25 09:05 GoldsteinE

I added a debug print here

https://github.com/emilk/egui/blob/da67465a6cd00e6c1732ecb96e0febc917d63c31/crates/eframe/src/native/wgpu_integration.rs#L783

and it gives a curious result:

got WindowEvent::Resized(PhysicalSize { width: 200, height: 200 }
got WindowEvent::Resized(PhysicalSize { width: 400, height: 400 }
got WindowEvent::Resized(PhysicalSize { width: 400, height: 400 }
# I start dragging here
got WindowEvent::Resized(PhysicalSize { width: 800, height: 800 }
got WindowEvent::Resized(PhysicalSize { width: 800, height: 800 }
got WindowEvent::Resized(PhysicalSize { width: 800, height: 800 }
got WindowEvent::Resized(PhysicalSize { width: 800, height: 800 }

Really not sure where this 800 comes from. I don’t get the same effect with bare winit repro, it’s just 200:200 once and then 400:400 always.

GoldsteinE avatar May 27 '25 10:05 GoldsteinE

I’ve traced it to this point:

https://github.com/rust-windowing/winit/blob/9cbce055d35d1270e7e4cc43ba4c81fffa6d1bb9/src/platform_impl/linux/wayland/state.rs#L266-L273

Here we get a WindowConfigure with new_size set to 400:400, which is a logical size, which gets multiplied by scale factor 2 and resizes the window to 800:800 pixels. For reasons I do not comprehend, in bare winit repro I only ever get 200:200 WindowConfigures (with the same exact winit version). The trail then leads to smithay-client-toolkit, which I don’t have time to debug at the moment.

In case it might be useful, I attach debug logs from both egui repro and bare winit repro.

egui-trace.log bare-winit-trace.log

I’m kinda out of my depth here. It seems that eframe does something that causes smithay-client-toolkit to produce this weird 400:400 configure. I can’t conclusively say whether the bug is in eframe, winit or smithay-client-toolkit, but I can’t reproduce it without eframe.

GoldsteinE avatar May 27 '25 11:05 GoldsteinE

The bug does not seem to reproduce on GNOME 48.2, so wl-roots or sway also might be part of the problem.

GoldsteinE avatar May 27 '25 12:05 GoldsteinE

Okay, I figured it out.

Here we guess scale by using primary monitors scale:

https://github.com/emilk/egui/blob/92fea8a18fe6c102a6879347cbd599f3daf163aa/crates/egui-winit/src/lib.rs#L1585-L1594

It’s always an integer number because fractional scale protocol didn’t happened yet. We use it to set window size as physical size here:

https://github.com/emilk/egui/blob/92fea8a18fe6c102a6879347cbd599f3daf163aa/crates/egui-winit/src/lib.rs#L1674-L1679

On window creation it’s “converted” to logical with fixed scale 1.0 (nice snippets end here because GitHub won’t render them т_т):

https://github.com/rust-windowing/winit/blob/9cbce055d35d1270e7e4cc43ba4c81fffa6d1bb9/src/platform_impl/linux/wayland/window/state.rs#L212-L213

In .set_resizable(false) it’s used to set min and max sizes of the window:

https://github.com/rust-windowing/winit/blob/9cbce055d35d1270e7e4cc43ba4c81fffa6d1bb9/src/platform_impl/linux/wayland/window/state.rs#L513-L514

When trying to resize, sway remembers that this window has a minimal size that’s higher than its actual size and upsizes it.


I’d say that this looks a bug both in winit and in eframe. winit shouldn’t blindly “convert” physical units to logical by doing * 1.0 and forget about it. eframe should probably either update min/max size after we have proper fractional scaling or use logical sizes everywhere (since even without identity-conversion in winit we would have wrong min/max sizes if fractional scale ≠ regular scale).

eframe doesn’t use the latest version of winit. ~I do not know whether the latest version has the same problem; the code structure changed considerably and it appears to be non-API-compatible. I’ll look into it some more, but I think that egui part of the problem should also be fixed.~ I confirmed that it still reproduces on winit v0.30.1. I created a winit issue: https://github.com/rust-windowing/winit/issues/4266

GoldsteinE avatar May 29 '25 20:05 GoldsteinE