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

Xilem example for http cats API, requiring workers and image component #571

Merged
merged 13 commits into from
Sep 3, 2024
516 changes: 511 additions & 5 deletions Cargo.lock

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,10 @@ cargo update -p package_name --precise 0.1.1
Licensed under the Apache License, Version 2.0
([LICENSE](LICENSE) or <http://www.apache.org/licenses/LICENSE-2.0>)

The font file (`RobotoFlex-Subset.ttf`) in `xilem/resources/fonts/roboto_flex/` is licensed solely as documented in that folder,
(and is not licensed under the Apache License, Version 2.0).
Some files used for examples are under different licenses:

- The font file (`RobotoFlex-Subset.ttf`) in `xilem/resources/fonts/roboto_flex/` is licensed solely as documented in that folder (and is not licensed under the Apache License, Version 2.0).
- The data file (`status.csv`) in `xilem/resources/data/http_cats_status/` is licensed solely as documented in that folder (and is not licensed under the Apache License, Version 2.0).

## Contribution

Expand Down
1 change: 0 additions & 1 deletion masonry/src/widget/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ impl Image {
/// Create an image drawing widget from an image buffer.
///
/// By default, the Image will scale to fit its box constraints ([`FillStrat::Fill`]).

#[inline]
pub fn new(image_data: ImageBuf) -> Self {
Image {
Expand Down
32 changes: 30 additions & 2 deletions xilem/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ license.workspace = true
repository.workspace = true
homepage.workspace = true
rust-version.workspace = true
exclude = ["/resources/fonts/roboto_flex/"]
exclude = ["/resources/fonts/roboto_flex/", "/resources/data/http_cats_status/"]

[package.metadata.docs.rs]
all-features = true
Expand All @@ -36,6 +36,16 @@ path = "examples/calc.rs"
# cdylib is required for cargo-apk
crate-type = ["cdylib"]

[[example]]
name = "http_cats"

[[example]]
name = "http_cats_android"
path = "examples/http_cats/main.rs"
# cdylib is required for cargo-apk
crate-type = ["cdylib"]


[[example]]
name = "stopwatch"

Expand Down Expand Up @@ -65,14 +75,28 @@ tracing.workspace = true
vello.workspace = true
smallvec.workspace = true
accesskit.workspace = true
tokio = { version = "1.39.1", features = ["rt", "rt-multi-thread", "time"] }
tokio = { version = "1.39.1", features = [
"rt",
"rt-multi-thread",
"time",
"sync",
] }

[dev-dependencies]
# Used for `variable_clock`
time = { workspace = true, features = ["local-offset"] }

# Used for http_cats
reqwest = { version = "0.12.7", default-features = false, features = [
# We use rustls as Android doesn't ship with openssl
# and this is likely to be easiest to get working.
"rustls-tls",
] }
image = { workspace = true, features = ["jpeg"] }

# Make wgpu use tracing for its spans.
profiling = { version = "1.0.15", features = ["profile-with-tracing"] }
anyhow = "1.0.86"

[target.'cfg(target_os = "android")'.dev-dependencies]
winit = { features = ["android-native-activity"], workspace = true }
Expand All @@ -81,3 +105,7 @@ winit = { features = ["android-native-activity"], workspace = true }
# Do not use when releasing a production app.
[package.metadata.android.application]
debuggable = true

[[package.metadata.android.uses_permission]]
# Needed for http_cats
name = "android.permission.INTERNET"
9 changes: 6 additions & 3 deletions xilem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,12 @@ Unless you explicitly state otherwise, any contribution intentionally submitted

Licensed under the Apache License, Version 2.0 ([LICENSE](LICENSE) or <http://www.apache.org/licenses/LICENSE-2.0>)

The font file (`RobotoFlex-Subset.ttf`) in `resources/fonts/roboto_flex/` is licensed solely as documented in that folder,
(and is not licensed under the Apache License, Version 2.0).
Note that this file is *not* distributed with the.
Some files used for examples are under different licenses:

* The font file (`RobotoFlex-Subset.ttf`) in `resources/fonts/roboto_flex/` is licensed solely as documented in that folder (and is not licensed under the Apache License, Version 2.0).
* The data file (`status.csv`) in `resources/data/http_cats_status/` is licensed solely as documented in that folder (and is not licensed under the Apache License, Version 2.0).

Note that these files are *not* distributed with the released crate.

[Masonry]: https://crates.io/crates/masonry
[Druid]: https://crates.io/crates/druid
Expand Down
4 changes: 4 additions & 0 deletions xilem/examples/http_cats/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Http Cats

An example demonstrating the use of Async web requests in Xilem to access the <https://http.cat/> API.
This also demonstrates image loading.
281 changes: 281 additions & 0 deletions xilem/examples/http_cats/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0

//! An example demonstrating the use of Async web requests in Xilem to access the <https://http.cat/> API.
//! This also demonstrates image loading.

use std::sync::Arc;

use vello::peniko::{Blob, Image};
use winit::{dpi::LogicalSize, error::EventLoopError, window::Window};
use xilem::{
view::{
button, flex, image, portal, prose, sized_box, spinner, worker, Axis, CrossAxisAlignment,
FlexExt, FlexSpacer,
},
Color, EventLoop, EventLoopBuilder, TextAlignment, WidgetView, Xilem,
};
use xilem_core::{fork, one_of::OneOf3};

/// The main state of the application.
struct HttpCats {
statuses: Vec<Status>,
// The currently active (http status) code.
selected_code: Option<u32>,
}

#[derive(Debug)]
struct Status {
code: u32,
message: &'static str,
image: ImageState,
}

#[derive(Debug)]
/// What operations have happened on a fetching image.
enum ImageState {
NotRequested,
Pending,
// Error,
Available(Image),
}

impl HttpCats {
fn view(&mut self) -> impl WidgetView<HttpCats> {
let left_column = portal(flex((
prose("Status"),
self.statuses
.iter_mut()
.map(Status::list_view)
.collect::<Vec<_>>(),
)));
let (info_area, worker_value) = if let Some(selected_code) = self.selected_code {
if let Some(selected_status) =
self.statuses.iter_mut().find(|it| it.code == selected_code)
{
// If we haven't requested the image yet, make sure we do so.
let value = match selected_status.image {
ImageState::NotRequested => {
// TODO: Should a view_function be editing `self`?
// This feels too imperative.
selected_status.image = ImageState::Pending;
Some(selected_code)
}
// If the image is pending, that means that worker already knows about it.
// We don't set the requested code to `selected_code` here because we could have been on
// a different view in-between, so we don't want to request the same image twice.
ImageState::Pending => None,
ImageState::Available(_) => None,
};
(OneOf3::A(selected_status.details_view()), value)
} else {
(
OneOf3::B(
prose(format!(
"Status code {selected_code} selected, but this was not found."
))
.alignment(TextAlignment::Middle)
.brush(Color::YELLOW),
),
None,
)
}
} else {
(
OneOf3::C(
prose("No selection yet made. Select an item from the sidebar to continue.")
.alignment(TextAlignment::Middle),
),
None,
)
};

// TODO: Should `web_image` be a built-in component?
jaredoconnell marked this conversation as resolved.
Show resolved Hide resolved

fork(
flex((
// Add padding to the top for Android. Still a horrible hack
FlexSpacer::Fixed(40.),
flex((
left_column.flex(1.),
portal(sized_box(info_area).expand_width()).flex(1.),
))
.direction(Axis::Horizontal)
.cross_axis_alignment(CrossAxisAlignment::Fill)
.must_fill_major_axis(true)
.flex(1.),
))
.must_fill_major_axis(true)
.cross_axis_alignment(CrossAxisAlignment::Fill),
worker(
worker_value,
|proxy, mut rx| async move {
while let Some(request) = rx.recv().await {
if let Some(code) = request {
let proxy = proxy.clone();
tokio::task::spawn(async move {
let url = format!("https://http.cat/{code}");
let result = image_from_url(&url).await;
match result {
// We choose not to handle the case where the event loop has ended
Ok(image) => drop(proxy.message((code, image))),
// TODO: Report in the frontend
Err(err) => {
tracing::warn!(
"Loading image for HTTP status code {code} from {url} failed: {err:?}"
);
}
}
});
}
}
},
|state: &mut HttpCats, (code, image): (u32, Image)| {
if let Some(status) = state.statuses.iter_mut().find(|it| it.code == code) {
status.image = ImageState::Available(image);
} else {
// TODO: Error handling?
}
},
),
)
}
}

/// Load a [`vello::peniko::Image`] from the given url.
async fn image_from_url(url: &str) -> anyhow::Result<Image> {
// TODO: Error handling
let response = reqwest::get(url).await?;
let bytes = response.bytes().await?;
let image = image::load_from_memory(&bytes)?.into_rgba8();
let width = image.width();
let height = image.height();
let data = image.into_vec();
Ok(Image::new(
Blob::new(Arc::new(data)),
vello::peniko::Format::Rgba8,
width,
height,
))
}

impl Status {
fn list_view(&mut self) -> impl WidgetView<HttpCats> {
let code = self.code;
flex((
// TODO: Reduce allocations here?
prose(self.code.to_string()),
prose(self.message),
FlexSpacer::Flex(1.),
// TODO: Spinner if image pending?
// TODO: Tick if image loaded?
button("Select", move |state: &mut HttpCats| {
state.selected_code = Some(code);
}),
FlexSpacer::Fixed(masonry::theme::SCROLLBAR_WIDTH),
))
.direction(Axis::Horizontal)
}

fn details_view(&mut self) -> impl WidgetView<HttpCats> {
let image = match &self.image {
ImageState::NotRequested => OneOf3::A(
prose("Failed to start fetching image. This is a bug!")
.alignment(TextAlignment::Middle),
),
ImageState::Pending => OneOf3::B(sized_box(spinner()).width(80.).height(80.)),
// TODO: Alt text?
ImageState::Available(image_data) => OneOf3::C(image(image_data.clone())),
DJMcNab marked this conversation as resolved.
Show resolved Hide resolved
};
flex((
prose(format!("HTTP Status Code: {}", self.code)).alignment(TextAlignment::Middle),
prose(self.message)
.text_size(20.)
.alignment(TextAlignment::Middle),
FlexSpacer::Fixed(10.),
image,
// TODO: Overlay on top of the image?
prose("Copyright ©️ https://http.cat").alignment(TextAlignment::End),
))
.main_axis_alignment(xilem::view::MainAxisAlignment::Start)
.cross_axis_alignment(CrossAxisAlignment::Fill)
.must_fill_major_axis(true)
}
}

fn run(event_loop: EventLoopBuilder) -> Result<(), EventLoopError> {
let data = HttpCats {
statuses: Status::parse_file(),
selected_code: None,
};

let app = Xilem::new(data, HttpCats::view);
let min_window_size = LogicalSize::new(200., 200.);

let window_attributes = Window::default_attributes()
.with_title("HTTP cats")
.with_resizable(true)
.with_min_inner_size(min_window_size);

app.run_windowed_in(event_loop, window_attributes)
}

impl Status {
/// Parse the supported HTTP cats.
fn parse_file() -> Vec<Self> {
let mut lines = STATUS_CODES_CSV.lines();
let first_line = lines.next();
assert_eq!(first_line, Some("code,message"));
lines.flat_map(Status::parse_single).collect()
}

fn parse_single(line: &'static str) -> Option<Self> {
let (code, message) = line.split_once(',')?;
Some(Self {
code: code.parse().ok()?,
message: message.trim(),
image: ImageState::NotRequested,
})
}
}

/// The status codes supported by <https://http.cat>, used under the MIT license.
/// Full details can be found in `xilem/resources/data/http_cats_status/README.md` from
/// the workspace root.
const STATUS_CODES_CSV: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/resources/data/http_cats_status/status.csv",
));

#[cfg(not(target_os = "android"))]
#[allow(dead_code)]
// This is treated as dead code by the Android version of the example, but is actually live
// This hackery is required because Cargo doesn't care to support this use case, of one
// example which works across Android and desktop
fn main() -> Result<(), EventLoopError> {
run(EventLoop::with_user_event())
}

// Boilerplate code for android: Identical across all applications

#[cfg(target_os = "android")]
// Safety: We are following `android_activity`'s docs here
// We believe that there are no other declarations using this name in the compiled objects here
#[allow(unsafe_code)]
#[no_mangle]
fn android_main(app: winit::platform::android::activity::AndroidApp) {
use winit::platform::android::EventLoopBuilderExtAndroid;

let mut event_loop = EventLoop::with_user_event();
event_loop.with_android_app(app);

run(event_loop).expect("Can create app");
}

// TODO: This is a hack because of how we handle our examples in Cargo.toml
// Ideally, we change Cargo to be more sensible here?
#[cfg(target_os = "android")]
#[allow(dead_code)]
fn main() {
unreachable!()
}
7 changes: 7 additions & 0 deletions xilem/resources/data/http_cats_status/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Copyright (c) 2015 Rogério Vicente

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Loading