1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00

Add Context::run_hosted_viewport for app-rendered viewports

Adds a way to run a pass for a viewport that the application paints itself,
e.g. into a texture. This is the building block for embedding one egui UI
inside another: a scaled, rotated or blurred sub-UI, a minimap, a live
preview, or an off-screen capture.

The nested-pass machinery already existed for immediate viewports, but two
things were missing for an application to use it:

* Nothing public sets `ViewportState::used`, so `end_pass` threw the child
  viewport's state away every pass. Hover, click and drag could never work.
* The child appeared in `FullOutput::viewport_output`, so an integration
  like eframe would open a native window for it.

`ViewportClass::Hosted` marks these viewports and keeps them out of
`viewport_output`, so the integration never learns they exist. In return the
application takes on what a backend normally does: supply the `RawInput`,
paint the shapes, apply the texture delta, and act on the platform output.

Also derives `Debug` for `ViewportClass`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Meurer
2026-07-30 20:41:27 +02:00
parent c69834e65a
commit 693c009fdf
3 changed files with 211 additions and 1 deletions

View File

@@ -2699,6 +2699,9 @@ impl ContextImpl {
let viewport_output = self
.viewports
.iter_mut()
// Hosted viewports are painted by the application itself, so the integration
// must not learn about them - it would create a window for them.
.filter(|(_, viewport)| viewport.class != ViewportClass::Hosted)
.map(|(&id, viewport)| {
let parent = *self.viewport_parents.entry(id).or_default();
let commands = if is_last {
@@ -4071,6 +4074,106 @@ impl Context {
})
}
/// Run a pass for a viewport that _you_ render yourself, e.g. into a texture.
///
/// This is the building block for embedding one egui UI inside another: a scaled,
/// rotated or blurred sub-UI, a minimap, a live preview, or an off-screen capture.
///
/// The new viewport shares this [`Context`]'s memory, style, fonts and texture atlas,
/// so it will look and behave like the rest of your app, but it gets its own input,
/// its own hit-testing, its own focus, and its own paint list.
///
/// You may call this from inside another pass (i.e. from your normal UI code); the
/// current pass is suspended and resumed around it. You must _not_ call it from
/// inside a closure that holds a lock on the [`Context`], such as the ones passed to
/// [`Self::memory_mut`] or [`Self::graphics_mut`].
///
/// You need to call this each pass in which the viewport should exist. If you skip a
/// pass, the viewport's state (input, focus, hit-test data) is thrown away.
///
/// The given `input` is used as-is, except that [`RawInput::viewport_id`] is set for
/// you, and the viewport's [`crate::ViewportInfo::parent`] and, if you left it at
/// `None`, its [`crate::ViewportInfo::native_pixels_per_point`] are inherited from the
/// calling viewport. You will usually want to set at least
/// [`RawInput::screen_rect`] and [`RawInput::events`].
///
/// Unlike [`Self::show_viewport_immediate`] and [`Self::show_viewport_deferred`], the
/// egui integration is never told that this viewport exists (see
/// [`ViewportClass::Hosted`]), so no window is created for it. In return, _you_ are
/// responsible for everything a backend would normally do: painting
/// [`FullOutput::shapes`], applying [`FullOutput::textures_delta`], and acting on
/// [`FullOutput::platform_output`].
///
/// Note that a [`Self::request_repaint`] from inside the viewport only marks _that_
/// viewport as needing a repaint, so bridge it to the viewport that owns the window:
///
/// ```
/// # let ctx = &egui::Context::default();
/// # let child_id = egui::ViewportId::from_hash_of("child");
/// let parent_id = ctx.viewport_id();
/// let (output, ()) = ctx.run_hosted_viewport(
/// child_id,
/// egui::RawInput {
/// screen_rect: Some(egui::Rect::from_min_size(
/// egui::Pos2::ZERO,
/// egui::vec2(320.0, 240.0),
/// )),
/// ..Default::default()
/// },
/// |ui| {
/// ui.label("I live in my own viewport");
/// },
/// );
/// if ctx.has_requested_repaint_for(&child_id) {
/// ctx.request_repaint_of(parent_id);
/// }
/// # output.drop_without_applying_deltas();
/// ```
///
/// See [`crate::viewport`] for more information about viewports.
#[must_use]
pub fn run_hosted_viewport<T>(
&self,
viewport_id: ViewportId,
mut input: RawInput,
mut ui_fn: impl FnMut(&mut Ui) -> T,
) -> (FullOutput, T) {
profiling::function_scope!();
let parent_id = self.write(|ctx| {
let parent_id = ctx.viewport_id();
ctx.viewport_parents.insert(viewport_id, parent_id);
let viewport = ctx.viewports.entry(viewport_id).or_default();
viewport.class = ViewportClass::Hosted;
viewport.used = true;
viewport.viewport_ui_cb = None; // we run it right here
parent_id
});
input.viewport_id = viewport_id;
let parent_ppp = self.input_for(parent_id, |i| {
i.raw
.viewports
.get(&parent_id)
.and_then(|info| info.native_pixels_per_point)
});
let info = input.viewports.entry(viewport_id).or_default();
info.parent = Some(parent_id);
info.native_pixels_per_point = info.native_pixels_per_point.or(parent_ppp);
// `run_ui` may run the ui function more than once (see `Self::request_discard`),
// so keep the value from the last pass.
let mut out = None;
let full_output = self.run_ui(input, |ui| {
out = Some(ui_fn(ui));
});
let out = out.expect("Bug in egui: the ui function was never called");
(full_output, out)
}
fn show_embedded_viewport<T>(
&self,
new_viewport_id: ViewportId,

View File

@@ -77,7 +77,7 @@ use epaint::{Pos2, Vec2};
// ----------------------------------------------------------------------------
/// The different types of viewports supported by egui.
#[derive(Clone, Copy, Default, Hash, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub enum ViewportClass {
/// The root viewport; i.e. the original window.
@@ -106,6 +106,17 @@ pub enum ViewportClass {
/// If you get this, it is because you are already wrapped in a [`crate::Window`]
/// inside of the parent viewport.
EmbeddedWindow,
/// A viewport that the application renders itself, e.g. into a texture.
///
/// The egui integration is never told about these viewports: they are left out of
/// [`crate::FullOutput::viewport_output`], so no window is created for them and no
/// [`ViewportCommand`] is delivered to them. The application that called
/// [`crate::Context::run_hosted_viewport`] owns the viewport completely: it supplies the
/// [`crate::RawInput`], and it paints the resulting shapes wherever it wants.
///
/// Create these with [`crate::Context::run_hosted_viewport`].
Hosted,
}
// ----------------------------------------------------------------------------

View File

@@ -0,0 +1,96 @@
//! Tests for [`egui::Context::run_hosted_viewport`].
use egui::{Context, Event, Pos2, RawInput, Rect, Sense, ViewportClass, ViewportId, pos2, vec2};
const CHILD_SIZE: egui::Vec2 = vec2(100.0, 100.0);
fn input(size: egui::Vec2, events: Vec<Event>) -> RawInput {
RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)),
events,
..Default::default()
}
}
/// The child viewport must keep its state (input, hit-test data, focus) between passes.
///
/// Hovering only works if the previous pass' widget rects survived, so a widget that
/// reports `hovered()` on the second pass proves the state was not thrown away.
#[test]
fn hosted_viewport_keeps_its_state_between_passes() {
let ctx = Context::default();
let child_id = ViewportId::from_hash_of("child");
let mut hovered = Vec::new();
for _ in 0..3 {
let parent_output = ctx.run_ui(input(vec2(300.0, 300.0), vec![]), |_ui| {
let (child_output, ()) = ctx.run_hosted_viewport(
child_id,
input(CHILD_SIZE, vec![Event::PointerMoved(pos2(10.0, 10.0))]),
|ui| {
let response = ui.allocate_response(vec2(50.0, 50.0), Sense::click());
hovered.push(response.hovered());
},
);
child_output.drop_without_applying_deltas();
});
parent_output.drop_without_applying_deltas();
}
assert_eq!(
hovered,
vec![false, true, true],
"the hosted viewport lost its state between passes"
);
}
/// A hosted viewport is the application's business, so the integration must never see it -
/// otherwise a backend like eframe would open a window for it.
#[test]
fn hosted_viewport_is_hidden_from_the_integration() {
let ctx = Context::default();
let child_id = ViewportId::from_hash_of("child");
let parent_output = ctx.run_ui(input(vec2(300.0, 300.0), vec![]), |_ui| {
let (child_output, ()) =
ctx.run_hosted_viewport(child_id, input(CHILD_SIZE, vec![]), |ui| {
ui.label("hello");
});
child_output.drop_without_applying_deltas();
});
assert!(
!parent_output.viewport_output.contains_key(&child_id),
"a hosted viewport must be left out of FullOutput::viewport_output"
);
assert_eq!(
ctx.viewport_for(child_id, |viewport| viewport.class),
ViewportClass::Hosted
);
parent_output.drop_without_applying_deltas();
}
/// The child gets its own paint list; its shapes must not leak into the parent's.
#[test]
fn hosted_viewport_shapes_are_separate() {
let ctx = Context::default();
let child_id = ViewportId::from_hash_of("child");
let parent_output = ctx.run_ui(input(vec2(300.0, 300.0), vec![]), |ui| {
ui.label("parent");
let (child_output, ()) =
ctx.run_hosted_viewport(child_id, input(CHILD_SIZE, vec![]), |ui| {
ui.label("child");
});
assert!(
!child_output.shapes.is_empty(),
"the child should have painted something"
);
child_output.drop_without_applying_deltas();
});
assert!(!parent_output.shapes.is_empty());
parent_output.drop_without_applying_deltas();
}