denise-winit 0.26.0

Desktop development and preview backend for Denise, on winit + softbuffer.
Documentation

denise-winit

crates.io docs.rs Licence

The desktop development and preview backend for Denise, a direct-rendering UI toolkit in Rust for embedded Linux and systems without a desktop environment.

This is not a deployment target. It exists so the core abstraction can be proven — and iterated on — without a Raspberry Pi on the desk. Shipping Denise on a desktop means shipping a compositor you did not need.

use denise::{Color, DamageTracker, Frame, InputEvent, Rect};
use denise_render::Canvas;
use denise_winit::{DeniseApp, WindowConfig, run};

struct Hello;

impl DeniseApp for Hello {
    fn update(&mut self, _events: &[InputEvent], _damage: &mut DamageTracker) {}

    fn render(&mut self, frame: &mut Frame<'_>, damage: &[Rect]) {
        let mut canvas = Canvas::new(frame);
        for region in damage {
            canvas.with_clip(*region).clear(Color::from_rgb888(0x1E1E2E));
        }
    }
}

# fn demo() {
run(WindowConfig::default(), Hello).unwrap();
# }

On a HiDPI display

WindowConfig::size is logical, so one number is the same amount of desk everywhere: 1280×800 on a Raspberry Pi is a 1280×800 surface, and on a 2× Retina Mac it is a window of the same apparent size with a 2560×1600 surface behind it.

Filling that surface is the application's job, and run_with is how it finds out it has to:

# use denise::{DamageTracker, Frame, InputEvent, Rect, Size};
# use denise_winit::{DeniseApp, WindowConfig, run_with};
# struct Panel;
# impl Panel { fn new(_: Size, _: f32) -> Self { Panel } }
# impl DeniseApp for Panel {
#     fn update(&mut self, _: &[InputEvent], _: &mut DamageTracker) {}
#     fn render(&mut self, _: &mut Frame<'_>, _: &[Rect]) {}
# }
# fn demo() {
// The surface, in physical pixels, and the display's scale factor — handed over
// at the first moment either exists. The application scales once, here, through
// `Theme::scaled`, `Rect::scaled` and its own text sizes.
run_with(WindowConfig::default(), Panel::new).unwrap();
# }

A later scale change — dragging the window to a second display — arrives as InputEvent::SurfaceResized, carrying the new factor.

Closing

The window manager's close button ends the run. The request is also queued as InputEvent::CloseRequested, but a window that is closing is not drawn again, so an application cannot count on reading it. An application that needs to stop the close — unsaved changes, a confirmation — overrides DeniseApp::close_requested to return false, and quits later through exit_requested once it has its answer.

Saving on the way out belongs in DeniseApp::exiting, the last call every application gets. It is made once per window however that window ends — its close button, exit_requested, its owner closing, the run ending — and it is the only call that hears about the ways out that never ask: on macOS, ⌘Q, Quit in the Dock and logging out end the process as soon as it returns, with no close request and without run returning. It cannot veto anything; by then the answer is in. A window is told before the window that opened it, and the main window last.

Secondary windows

A desktop has a window manager, so a settings form can be a window rather than a scene in the same buffer. DeniseApp::take_windows is the whole of it: hand back a WindowRequest and the backend opens a window with its own surface, its own damage tracker and its own frame deadline, running an application you built.

# use denise::{DamageTracker, Frame, InputEvent, Rect, Size};
# use denise_winit::{DeniseApp, Modality, WindowConfig, WindowRequest};
# struct Settings;
# impl Settings { fn new(_: Size, _: f32) -> Self { Settings } }
# impl DeniseApp for Settings {
#     fn update(&mut self, _: &[InputEvent], _: &mut DamageTracker) {}
#     fn render(&mut self, _: &mut Frame<'_>, _: &[Rect]) {}
# }
# struct Main { wanted: bool }
# impl Main {
fn take_windows(&mut self) -> Vec<WindowRequest> {
    if !std::mem::take(&mut self.wanted) {
        return Vec::new();
    }
    // Modeless and owned by the window that asked, which is the default: above it,
    // closed with it, and the main window stays usable. `Modality::Modal` blocks
    // the owner instead; `Modality::Independent` is a window of its own.
    vec![WindowRequest::new(WindowConfig::default(), Settings::new)]
}
# }

A form is an ordinary DeniseApp — the same trait the main window implements — so there is no form type, no base class, and nothing that makes a "dialog" different from a "window" except the Modality asked for. It is built through the same (Size, f32) callback run_with uses, because a form opens on whichever display its owner is on and needs that display's scale factor.

Closing the main window ends the run. Closing any other window closes that window and everything it opened. Nothing can close a window it did not build: a form ends itself through exit_requested, which is also how the window that opened it asks — through state they share, which the application owns and this crate never sees.

Modality is enforced here, not by the platform. A window with a modal over it stops receiving input and keeps repainting; a press on it raises the modal instead. That is the same on all three platforms, which the platforms themselves are not:

Owned z-order Owner blocked
Windows with_owner_window set_enable(false) — a real Win32 modal
macOS addChildWindow:ordered: nothing; runModal would fight winit's loop
X11 / Wayland nothing reachable through winit nothing

So the platform calls are appearance, and deleting them would cost looks rather than correctness. On Linux the window manager may put a modal behind its owner; it still cannot be typed into. cargo run -p forms is the whole feature in one example.

This is desktop-only and stays here. denise-ui is untouched by it and knows nothing about a second tree; a kiosk build links denise-drm and never compiles a line of this. The portable way to ask a question is still Ui::push_scene.

What a frame costs here, and why it is not what a panel costs

A frame in which nothing changed costs nothing: the loop skips the acquire, the paint and the present entirely. An idle window sits at well under 1% of a core.

A frame in which something changed is presented by the platform's own path, and those differ more than they should. win32 BitBlts the damage rectangles into a persistent DIB section; x11, wayland and kms do the equivalent; all of them report a real buffer age, so only what changed is ever copied.

macOS does not go through softbuffer at all here. Its CoreGraphics backend allocates and zeroes a fresh buffer on every buffer_mut, reports an age of 0 so the shadow has to be copied in full, and discards the damage rectangles in present_with_damage — three passes over the whole surface per frame, which on a 2560×1600 Retina window cost 48.8% of a core to animate one spinner. So this backend presents through denise-macos instead: a pair of IOSurfaces the compositor reads in place, alternated so CoreAnimation sees a new object each frame. The same window, the same spinner, at 60 frames a second rather than 20:

CPU
softbuffer, 60 fps 48.8%
softbuffer, 20 fps 24.5%
IOSurface pair, 60 fps 3.5%

For reference, the same tree on a Raspberry Pi 3A+ over DRM is 4.2%, and on Windows 0.5%. The desktop backends are now within sight of the hardware, which is the only claim this crate ever wanted to make.

How much of that a panel spends is the application's decision rather than this crate's: Ui::set_motion sets the rate everything animates at, and Motion::None leaves the tree asking for no wake at all — at which point next_frame_in answers None and the loop blocks on input, the state a kiosk should be in almost all the time. cargo run -p gallery -- --motion 33 is the lever in one flag.

Why it earns its place

Because the alternative is developing blind. A backend that produces the same InputEvents and honours the same Surface contract as DRM means a panel can be written and reviewed on a laptop and then run unchanged on the hardware — and when it does not, the difference is in the backend rather than in the abstraction, which is a far smaller place to look.

It also keeps the contract honest: two independent implementations of Surface is the minimum at which "the trait describes the problem" stops being an assertion.

Runs on Linux, macOS and Windows. Windowing and input come from winit everywhere; presentation comes from softbuffer, except on macOS where it comes from denise-macos for the reasons above — or, behind the gpu feature, from denise-wgpu on any of them. They are the only dependencies in the whole workspace that are there purely for convenience.

On the GPU

Behind the gpu feature, a window can present through denise-wgpu instead of a buffer of words. Nothing about the window, the input or the scheduling changes; only what draws. Ask for it with Present::Gpu, and draw through paint instead of render:

use denise::{BufferAge, DamageTracker, InputEvent, Pen, Rect, theme};
use denise_ui::Ui;
use denise_winit::{DeniseApp, Present, WindowConfig, run_with};

struct Panel {
    ui: Ui<()>,
}

impl DeniseApp for Panel {
    fn update(&mut self, events: &[InputEvent], _damage: &mut DamageTracker) {
        self.ui.handle(events);
    }

    // The painter-agnostic half. On the GPU `age` is always `Undefined`; on
    // the software path `render` is provided and calls this with the frame's.
    fn paint(&mut self, pen: &mut Pen<'_>, age: BufferAge, _damage: &[Rect]) -> bool {
        self.ui.paint_with(pen, age);
        self.ui.presented();
        true
    }
}

# fn main() -> Result<(), denise_winit::Error> {
run_with(
    WindowConfig {
        title: "On the GPU".into(),
        present: Present::Gpu,
        ..WindowConfig::default()
    },
    |size, scale| Panel {
        ui: Ui::new(size, theme::DARK.scaled(scale)),
    },
)
# }

Every GPU frame is a full repaint. A swapchain keeps no reliable buffer age and a desktop GPU redraws a window for nothing, so the damage tracker's work is not needed there and BufferAge::Undefined is what tells the application so. An application that implements only render — one that wants a Frame — gets Error::Gpu at window creation rather than a blank window.

This is for the designer on a large display. A preview of a panel does not need it, and a panel never has it: the kiosk path is unchanged.

Where this sits

Implements denise::Surface and denise::InputSource. Swap it for denise-drm plus denise-evdev to ship, usually behind one cfg in the application.

For putting a Denise panel inside an existing desktop application, this is the wrong crate — see denise-win32, denise-macos or denise-activex, which embed rather than own the window.

Status

M0 complete, and used continuously since. Part of Denise — see the repository README for the whole picture.

MIT licensed.