Skip to content

escape-hatch

素の egui へ降りる 4 つの道: クロージャ、リーフ、ペインタ、入れ子の Cx。

egui-reactor が包んでいるのは egui の便利な一部分で、全部ではありませんし、全部である必要もありません。最後はすべて &mut egui::Ui なので、egui にできることは呼び出し 1 回の距離にあります。4 つの節が 4 つの道筋です。木の途中に普通のコードを書く {view(|cx| ..)}、いまいる場所に egui を描くcx.leaf(&style, |ui| ..)、自分で描くための「確保して描く」、そして egui のコンテナのクロージャの中でフックを使うための、内側の Ui に対する新しい Cx。

落とし穴は 1 節目にあり、それが leaf の存在理由です。cx.ui() はどこからでも安全に 読め ますが、<View> の中では、それはレイアウト木全体が始まった場所の Ui であって、いまいる位置ではありません。これを通して描くと、ウィジェットはレイアウトの外、木の左上隅に出ます。cx.leaf は代わりにノードを足し、そのノードの Ui を渡します。

細かい点が 2 つあります。プログレスバーは leaf ではなく leaf_fill を使い、両方の軸に大きさを与えています。広がるウィジェットは、描いたものではなくスタイルで大きさが決まり、auto のままの軸はウィンドウ全部を取ります。測るものが無いときの「埋める」は、そういう意味だからです。それからスパークラインは&mut egui::Ui を取る普通の関数です。このライブラリのことを何も知りません。それがまさに要点です。

これは回避策ではありません。egui-reactor-elements の要素も同じ呼び出しで書かれています。裏口はありません。同じ 4 つをもう少し言葉で説明したものがエスケープハッチ です。

自分で動かす ​

sh
cargo run -p escape-hatch
trunk serve --config examples/escape-hatch/Trunk.toml

ソースは examples/escape-hatch/src/lib.rs です。

rust
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;

/// The size of the sparkline, in points. Fixed, so the leaf and the painter
/// agree without measuring each other.
const PLOT: egui::Vec2 = egui::vec2(220.0, 60.0);

/// The height of the progress bar, for the same reason as [`PLOT`].
const BAR_H: f32 = 20.0;

/// A deterministic sample, so the picture is the same on every machine.
fn sample(index: usize) -> f32 {
    (index as f32 * 0.7).sin()
}

#[component]
pub fn App(cx: &mut Cx) {
    let mut progress = use_state(cx, || 0.35f32);
    let mut colour = use_state(cx, || egui::Color32::from_rgb(0x6c, 0xa0, 0xdc));
    let mut samples = use_state(cx, Vec::<f32>::new);

    let value = *progress;
    let plotted: Vec<f32> = samples.clone();

    rsx! {
        <View direction="column" gap={8} p={12} grow={1.0}>
            <Text size={22.0} strong>"escape hatches"</Text>

            <Text strong>"1. a closure"</Text>
            <Text>"`{view(|cx| ..)}` is ordinary code in the middle of a tree."</Text>
            {view(|cx| {
                // Hooks work in here: this is the same `Cx` the component has,
                // and the slot is keyed by this line.
                let mut opened = use_state(cx, || 0u32);
                // Reading from `cx.ui()` is always fine.
                let dark = cx.ui().visuals().dark_mode;

                // Drawing is the part that needs care. Inside a `<View>`,
                // `cx.ui()` is the *tree's* `Ui`, not this position in it, so
                // painting through it lands outside the layout — in the
                // top-left corner of the tree, over whatever is there. `leaf`
                // is what puts a rectangle in the flow and hands you the `Ui`
                // for it. Note what is not in here: a spinner. It animates, so
                // it asks for a repaint every frame, which is fine in an app
                // and awkward in a test.
                cx.leaf(&ItemStyle::default(), |ui| {
                    ui.horizontal(|ui| {
                        ui.code("cx.leaf");
                        ui.small(if dark { "dark" } else { "light" });
                        if ui.link("open the docs").clicked() {
                            *opened += 1;
                            ui.ctx()
                                .open_url(egui::OpenUrl::new_tab("https://docs.rs/egui"));
                        }
                        ui.label(format!("opened {} times", *opened));
                    });
                });
            })}

            <Separator/>

            <Text strong>"2. a leaf"</Text>
            <Text>"Widgets with no element of their own, laid out by taffy."</Text>
            <Slider bind={progress.bind()} range={0.0..=1.0} label="progress"/>
            <View direction="row" gap={8} align="center">
                {view(move |cx| {
                    // `leaf_fill`, not `leaf`: a progress bar fills what it is
                    // given, so it is sized by the style rather than by what it
                    // drew last frame. Both axes need a size — an axis left
                    // `auto` on a filling leaf claims the whole window, because
                    // that is what "fill" means with nothing to measure.
                    cx.leaf_fill(&ItemStyle::default().w(PLOT.x).h(BAR_H), |ui| {
                        ui.add(egui::ProgressBar::new(value).show_percentage());
                    });
                })}
                // `egui::ProgressBar` puts nothing in the accessibility tree,
                // so the reading is spelled out for anyone (or any test) that
                // cannot see the bar.
                <Text>{format!("progress {:.2}", value)}</Text>
            </View>
            {view(move |cx| {
                cx.leaf(&ItemStyle::default(), |ui| {
                    ui.horizontal(|ui| {
                        ui.color_edit_button_srgba(colour.bind());
                        ui.label("egui's colour picker, unwrapped");
                    });
                });
            })}

            <Separator/>

            <Text strong>"3. a painter"</Text>
            <Text>"Allocate a rectangle, then draw into it."</Text>
            <View direction="row" gap={8} align="center">
                <Button on_click={|| {
                    let next = sample(samples.len());
                    samples.push(next);
                }}>"add sample"</Button>
                <Button on_click={|| samples.clear()}>"clear"</Button>
                <Text>{format!("{} samples", plotted.len())}</Text>
            </View>
            {view(move |cx| {
                cx.leaf_fill(&ItemStyle::default().w(PLOT.x).h(PLOT.y), |ui| {
                    sparkline(ui, &plotted);
                });
            })}

            <Separator/>

            <Text strong>"4. a nested Cx"</Text>
            <Text>"Hooks inside an egui container's closure."</Text>
            <Nested/>
        </View>
    }
}

/// Draw `values` as a polyline, filling whatever rectangle the leaf was given.
///
/// This is what `<Canvas>` will wrap: allocate, then paint. Nothing here needs
/// egui-reactor at all — it takes a `&mut egui::Ui` and no more.
fn sparkline(ui: &mut egui::Ui, values: &[f32]) {
    let (rect, _response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
    let painter = ui.painter();
    painter.rect_filled(rect, 2.0, ui.visuals().extreme_bg_color);

    if values.len() < 2 {
        painter.text(
            rect.center(),
            egui::Align2::CENTER_CENTER,
            "add two samples",
            egui::TextStyle::Small.resolve(ui.style()),
            ui.visuals().weak_text_color(),
        );
        return;
    }

    let step = rect.width() / (values.len() - 1) as f32;
    let points: Vec<egui::Pos2> = values
        .iter()
        .enumerate()
        .map(|(i, value)| {
            // Samples run -1..1; the y axis points down.
            let t = (1.0 - value) / 2.0;
            egui::pos2(
                rect.left() + i as f32 * step,
                rect.top() + t * rect.height(),
            )
        })
        .collect();
    painter.add(egui::Shape::line(
        points,
        egui::Stroke::new(1.5, ui.visuals().hyperlink_color),
    ));
}

/// An egui container drawn by hand, with egui-reactor hooks inside it.
///
/// This is how every container element in `egui-reactor-elements` is written:
/// copy `store` and the scope id out of the `Cx`, call the egui container, and
/// build a new `Cx` around the `Ui` it hands back. `cx.scope` keys the inner
/// hooks so they do not collide with the outer ones.
#[component]
fn Nested(cx: &mut Cx) {
    let mut outer = use_state(cx, || 0i32);
    let (store, scope) = (cx.store, cx.scope_id());
    let outside = *outer;

    rsx! {
        <View direction="column" gap={4}>
            <View direction="row" gap={8} align="center">
                <Button on_click={|| *outer += 1}>"outer +"</Button>
                <Text>{format!("outer: {outside}")}</Text>
            </View>
            {view(move |cx| {
                cx.leaf(&ItemStyle::default(), move |ui| {
                    ui.group(|ui| {
                        let mut cx = Cx::new(store, ui, scope);
                        cx.scope("inner", |cx| {
                            let mut inner = use_state(cx, || 0i32);
                            let value = *inner;
                            let ui = cx.ui();
                            ui.horizontal(|ui| {
                                if ui.button("inner +").clicked() {
                                    *inner += 1;
                                }
                                ui.label(format!("inner: {value}"));
                            });
                        });
                    });
                });
            })}
        </View>
    }
}

MIT または Apache-2.0 ライセンスで公開しています。