Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Egui router #24

Merged
merged 23 commits into from
Jun 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c265b75
Add egui router crate
lucasmerlin May 14, 2024
0511cb6
Implement router transitions
lucasmerlin May 15, 2024
0bfce1b
Improved router transitions
lucasmerlin May 15, 2024
2c25957
Add async route
lucasmerlin May 15, 2024
ff328e9
Add egui_router to hello_egui
lucasmerlin May 15, 2024
bd0c68b
Add router replace function
lucasmerlin May 15, 2024
63197cb
Make route take mut self
lucasmerlin May 15, 2024
2708dbe
Keep the ui id consistent while transitioning
lucasmerlin May 15, 2024
2d3af03
Fix replace state popping route on 404
lucasmerlin May 15, 2024
a41528e
Round last_width in virtual_list to prevent flicker through rounding …
lucasmerlin May 15, 2024
1d0ec09
Fix ids not being stable (really this time) and add fade_up transition
lucasmerlin May 15, 2024
6d926a8
Add active_route fn
lucasmerlin May 15, 2024
967cfaa
Make transition pub
lucasmerlin May 15, 2024
ad77fa7
Refactor fancy example to use router
lucasmerlin May 19, 2024
c62051b
Make RequestRepaintContext public, impl AsRequestRepaint for it and m…
lucasmerlin Jun 18, 2024
74af212
Add basic browser history implementation
lucasmerlin Jun 28, 2024
feefd54
Router builder and load active route in browser history
lucasmerlin Jun 28, 2024
1f5ae73
Add redirect route kind
lucasmerlin Jun 28, 2024
ce914ef
Fix double back
lucasmerlin Jun 28, 2024
f5107f0
Add base_href to router
lucasmerlin Jun 29, 2024
5e9815f
Add async route support
lucasmerlin Jun 29, 2024
04035f8
Proper error handling and error and loading ui customization
lucasmerlin Jun 29, 2024
360093b
Clippy fixes
lucasmerlin Jun 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ tag-prefix = "hello_egui-"
all-features = true

[features]
all = ["animation", "dnd", "form", "inbox", "infinite_scroll", "pull_to_refresh", "suspense", "thumbhash", "virtual_list"]
all = ["animation", "dnd", "form", "inbox", "infinite_scroll", "pull_to_refresh", "router", "suspense", "thumbhash", "virtual_list"]
full = ["all", "async", "tokio"]

animation = ["dep:egui_animation"]
Expand All @@ -25,6 +25,7 @@ form = ["dep:egui_form"]
inbox = ["dep:egui_inbox"]
infinite_scroll = ["dep:egui_infinite_scroll"]
pull_to_refresh = ["dep:egui_pull_to_refresh"]
router = ["dep:egui_router"]
suspense = ["dep:egui_suspense"]
thumbhash = ["dep:egui_thumbhash"]
tokio = ["egui_suspense/tokio", "egui_infinite_scroll/tokio"]
Expand All @@ -37,6 +38,7 @@ egui_inbox = { workspace = true, optional = true }
egui_form = { workspace = true, optional = true }
egui_infinite_scroll = { workspace = true, optional = true }
egui_pull_to_refresh = { workspace = true, optional = true }
egui_router = { workspace = true, optional = true }
egui_suspense = { workspace = true, optional = true }
egui_thumbhash = { workspace = true, optional = true }
egui_virtual_list = { workspace = true, optional = true }
Expand All @@ -53,6 +55,7 @@ hello_egui_utils = { path = "./crates/hello_egui_utils", version = "0.4.0" }
egui_form = { path = "./crates/egui_form", version = "0.1.1" }
egui_inbox = { path = "./crates/egui_inbox", version = "0.4.1" }
egui_pull_to_refresh = { path = "./crates/egui_pull_to_refresh", version = "0.4.0" }
egui_router = { path = "./crates/egui_router", version = "0.1.0" }
egui_suspense = { path = "./crates/egui_suspense", version = "0.4.0" }
egui_virtual_list = { path = "./crates/egui_virtual_list", version = "0.3.0" }
egui_infinite_scroll = { path = "./crates/egui_infinite_scroll", version = "0.3.0" }
Expand Down
2 changes: 1 addition & 1 deletion crates/egui_inbox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ async = ["dep:hello_egui_utils", "hello_egui_utils/async", "dep:futures-channel"
tokio = ["async", "hello_egui_utils/tokio"]
egui = ["dep:egui"]
default = ["egui"]
broadcast = []
broadcast = ["dep:hello_egui_utils"]
type_inbox = ["dep:type-map", "dep:hello_egui_utils"]
type_broadcast = ["dep:type-map", "broadcast", "dep:hello_egui_utils"]

Expand Down
26 changes: 14 additions & 12 deletions crates/egui_inbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,15 @@ where
}
}

impl RequestRepaintTrait for &Box<dyn RequestRepaintTrait> {
fn request_repaint(&self) {
self.as_ref().request_repaint();
}
}

#[derive(Clone)]
enum RequestRepaintInner {
#[cfg(feature = "egui")]
Ctx(egui::Context),
Box(Box<dyn RequestRepaintTrait + Send + Sync>),
Arc(Arc<dyn RequestRepaintTrait + Send + Sync>),
}

/// Usually holds a reference to [egui::Context], but can also hold a boxed callback.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct RequestRepaintContext(RequestRepaintInner);

impl RequestRepaintContext {
Expand All @@ -58,15 +53,15 @@ impl RequestRepaintContext {
where
F: Fn() + Send + Sync + 'static,
{
Self(RequestRepaintInner::Box(Box::new(f)))
Self(RequestRepaintInner::Arc(Arc::new(f)))
}

/// Create a new [RequestRepaintContext] from something that implements [RequestRepaintTrait].
pub fn from_trait<T>(t: T) -> Self
where
T: RequestRepaintTrait + Send + Sync + 'static,
{
Self(RequestRepaintInner::Box(Box::new(t)))
Self(RequestRepaintInner::Arc(Arc::new(t)))
}

/// Create a new [RequestRepaintContext] from an [egui::Context].
Expand All @@ -77,10 +72,11 @@ impl RequestRepaintContext {
}

impl RequestRepaintContext {
fn request_repaint(&self) {
/// Request a repaint.
pub fn request_repaint(&self) {
match &self.0 {
RequestRepaintInner::Ctx(ctx) => ctx.request_repaint(),
RequestRepaintInner::Box(boxed) => boxed.request_repaint(),
RequestRepaintInner::Arc(boxed) => boxed.request_repaint(),
}
}
}
Expand All @@ -97,6 +93,12 @@ pub trait AsRequestRepaint {
fn as_request_repaint(&self) -> RequestRepaintContext;
}

impl AsRequestRepaint for RequestRepaintContext {
fn as_request_repaint(&self) -> RequestRepaintContext {
self.clone()
}
}

#[cfg(feature = "egui")]
mod egui_impl {
use crate::{AsRequestRepaint, RequestRepaintContext};
Expand Down
2 changes: 1 addition & 1 deletion crates/egui_pull_to_refresh/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ impl PullToRefreshState {
match self {
PullToRefreshState::Idle => Some(0.0),
PullToRefreshState::Dragging { distance, .. } => {
Some((distance / min_distance).min(1.0).max(0.0) as f64)
Some((distance / min_distance).clamp(0.0, 1.0) as f64)
}
PullToRefreshState::DoRefresh => Some(1.0),
PullToRefreshState::Refreshing => None,
Expand Down
36 changes: 36 additions & 0 deletions crates/egui_router/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[package]
name = "egui_router"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[features]
async = ["egui_suspense/async"]

[[example]]
name = "async_router"
required-features = ["async"]

[[example]]
name = "router"
required-features = ["async"]

[dependencies]
egui.workspace = true
egui_inbox.workspace = true
egui_suspense = { workspace = true, optional = true }

matchit = "0.8"

[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3", features = ["History", "PopStateEvent", "HtmlCollection"] }
js-sys = "0.3"
wasm-bindgen = "0.2"

[dev-dependencies]
egui_inbox = { workspace = true, features = ["type_inbox"] }
eframe = { workspace = true, default-features = true }
egui_animation = { workspace = true }
tokio = { version = "1", features = ["full"] }
egui_suspense = { workspace = true, features = ["async", "tokio"] }
11 changes: 11 additions & 0 deletions crates/egui_router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# egui_router

A single-page application router for [egui](https://github.com/emilk/egui).

It supports:

- Customizable route transition animations
- Axum-like route matching and handler functions

Check out the [hello_egui demo](https://lucasmerlin.github.io/hello_egui/), which internally uses
egui_router to route between the examples and crates.
123 changes: 123 additions & 0 deletions crates/egui_router/examples/async_router.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
use eframe::NativeOptions;
use egui::{CentralPanel, Color32, Frame, ScrollArea, Ui};
use egui_inbox::{UiInbox, UiInboxSender};
use egui_router::{EguiRouter, HandlerError, HandlerResult, OwnedRequest, Route};

type AppState = UiInboxSender<RouterMessage>;

enum RouterMessage {
Navigate(String),
Back,
}

#[tokio::main]
async fn main() -> eframe::Result<()> {
let mut router: Option<EguiRouter<AppState>> = None;

let inbox = UiInbox::new();
let mut sender = inbox.sender();

eframe::run_simple_native(
"Router Example",
NativeOptions::default(),
move |ctx, _frame| {
let router = router.get_or_insert_with(|| {
EguiRouter::builder()
.error_ui(|ui, state: &AppState, error| {
ui.label(format!("Error: {}", error));
if ui.button("back").clicked() {
state.clone().send(RouterMessage::Back).ok();
}
})
.loading_ui(|ui, _| {
ui.label("Loading...");
ui.spinner();
})
.route("/", home)
.async_route("/post/{id}", post)
.default_path("/")
.build(&mut sender)
});

inbox.read(ctx).for_each(|msg| match msg {
RouterMessage::Navigate(route) => {
router.navigate(&mut sender, route).ok();
}
RouterMessage::Back => {
router.back().ok();
}
});

CentralPanel::default().show(ctx, |ui| {
router.ui(ui, &mut sender);
});
},
)
}

fn home() -> impl Route<AppState> {
|ui: &mut Ui, inbox: &mut UiInboxSender<RouterMessage>| {
background(ui, ui.style().visuals.faint_bg_color, |ui| {
ui.heading("Home!");

ui.label("Navigate to post:");

if ui.link("Post 1").clicked() {
inbox
.send(RouterMessage::Navigate("/post/1".to_string()))
.ok();
}

if ui.link("Post 2").clicked() {
inbox
.send(RouterMessage::Navigate("/post/2".to_string()))
.ok();
}

if ui.link("Error Post").clicked() {
inbox
.send(RouterMessage::Navigate("/post/error".to_string()))
.ok();
}
});
}
}

async fn post(request: OwnedRequest<AppState>) -> HandlerResult<impl Route<AppState>> {
let id = request.params.get("id").map(ToOwned::to_owned);

tokio::time::sleep(std::time::Duration::from_secs(1)).await;

if id.as_deref() == Some("error") {
Err(HandlerError::Message("Error Loading Post!".to_string()))?;
}

Ok(move |ui: &mut Ui, sender: &mut AppState| {
background(ui, ui.style().visuals.extreme_bg_color, |ui| {
ScrollArea::vertical().show(ui, |ui| {
if let Some(id) = &id {
ui.label(format!("Post: {}", id));

if ui.button("back").clicked() {
sender.send(RouterMessage::Back).ok();
}

ui.label(include_str!("../../../README.md"));
} else {
ui.label("Post not found");
if ui.button("back").clicked() {
sender.send(RouterMessage::Back).ok();
}
}
});
});
})
}

fn background(ui: &mut Ui, color: Color32, content: impl FnOnce(&mut Ui)) {
Frame::none().fill(color).inner_margin(16.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_height(ui.available_height());
content(ui);
});
}
Loading
Loading