mirror of
https://github.com/danbulant/cushy
synced 2026-06-20 15:01:11 +00:00
Unit-tested, auto-generated screenshots
This commit adds my first take at creating a harness for a user's guide using the new capture functionality. The example has tests that ensure the align widget creates the expected results.
This commit is contained in:
parent
eb20133116
commit
a197bb5e81
15 changed files with 496 additions and 61 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -988,6 +988,13 @@ dependencies = [
|
|||
"bitflags 2.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "guide-examples"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"cushy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.2.1"
|
||||
|
|
@ -1180,7 +1187,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
|||
[[package]]
|
||||
name = "kludgine"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/khonsulabs/kludgine#6b0d5fa0477daaf79f75a6f7dad819377a45e645"
|
||||
source = "git+https://github.com/khonsulabs/kludgine#8017775228d22b5efce6d6b7a89e81dfc9b25961"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"alot",
|
||||
|
|
@ -1193,6 +1200,7 @@ dependencies = [
|
|||
"intentional",
|
||||
"justjson",
|
||||
"lyon_tessellation",
|
||||
"palette",
|
||||
"pollster",
|
||||
"smallvec",
|
||||
"unicode-bidi",
|
||||
|
|
@ -2332,9 +2340,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.47"
|
||||
version = "2.0.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb"
|
||||
checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["cushy-macros"]
|
||||
members = ["cushy-macros", "guide/guide-examples"]
|
||||
|
||||
[package]
|
||||
name = "cushy"
|
||||
|
|
@ -41,6 +41,7 @@ zeroize = "1.6.1"
|
|||
unicode-segmentation = "1.10.1"
|
||||
pollster = "0.3.0"
|
||||
png = "0.17.10"
|
||||
image = { version = "0.24.7", features = ["png"] }
|
||||
|
||||
|
||||
# [patch.crates-io]
|
||||
|
|
@ -60,7 +61,6 @@ opt-level = 2
|
|||
|
||||
[dev-dependencies]
|
||||
rand = "0.8.5"
|
||||
image = { version = "0.24.7", features = ["png"] }
|
||||
|
||||
[profile.release]
|
||||
# debug = true
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ fn main() {
|
|||
.unwrap();
|
||||
let initial_point = Point::new(Px::new(140), Px::new(150));
|
||||
recorder.set_cursor_position(initial_point);
|
||||
recorder.set_cursor_visible(true);
|
||||
recorder.refresh().unwrap();
|
||||
let mut animation = recorder.record_animated_png(60);
|
||||
animation
|
||||
|
|
|
|||
|
|
@ -12,15 +12,7 @@ fn main() {
|
|||
.size(Size::new(320, 240))
|
||||
.finish()
|
||||
.unwrap();
|
||||
image::save_buffer_with_format(
|
||||
"examples/offscreen.png",
|
||||
recorder.bytes(),
|
||||
recorder.window.size().width.get(),
|
||||
recorder.window.size().height.get(),
|
||||
image::ColorType::Rgb8,
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
recorder.image().save("examples/offscreen.png").unwrap();
|
||||
|
||||
// Creating a recorder with alpha makes the virtual window transparent.
|
||||
let recorder = ui()
|
||||
|
|
@ -29,15 +21,7 @@ fn main() {
|
|||
.size(Size::new(320, 240))
|
||||
.finish()
|
||||
.unwrap();
|
||||
image::save_buffer_with_format(
|
||||
"examples/offscreen-transparent.png",
|
||||
recorder.bytes(),
|
||||
recorder.window.size().width.get(),
|
||||
recorder.window.size().height.get(),
|
||||
image::ColorType::Rgba8,
|
||||
image::ImageFormat::Png,
|
||||
)
|
||||
.unwrap();
|
||||
recorder.image().save("examples/offscreen.png").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
1
guide/.gitignore
vendored
Normal file
1
guide/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
book
|
||||
6
guide/book.toml
Normal file
6
guide/book.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[book]
|
||||
authors = ["Jonathan Johnson"]
|
||||
language = "en"
|
||||
multilingual = false
|
||||
src = "src"
|
||||
title = "Cushy User's Guide"
|
||||
8
guide/guide-examples/Cargo.toml
Normal file
8
guide/guide-examples/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "guide-examples"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
cushy = { version = "0.2.0", path = "../../" }
|
||||
137
guide/guide-examples/examples/align.rs
Normal file
137
guide/guide-examples/examples/align.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use cushy::figures::units::{Lp, Px};
|
||||
use cushy::figures::{Point, Size};
|
||||
use cushy::styles::{Edges, ThemePair};
|
||||
use cushy::widget::MakeWidget;
|
||||
use cushy::widgets::Space;
|
||||
use guide_examples::BookExample;
|
||||
|
||||
fn content() -> impl MakeWidget {
|
||||
Space::primary().size(Size::squared(Px::new(32)))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
BookExample::new(
|
||||
"align-horizontal",
|
||||
"Default Behavior"
|
||||
.and(content())
|
||||
.and("align_left()")
|
||||
.and({
|
||||
// ANCHOR: align-left
|
||||
content().align_left()
|
||||
// ANCHOR_END: align-left
|
||||
})
|
||||
.and("pad_by().align_left()")
|
||||
.and({
|
||||
// ANCHOR: align-left-pad
|
||||
content()
|
||||
.pad_by(Edges::default().with_left(Lp::inches(1)))
|
||||
.align_left()
|
||||
// ANCHOR_END: align-left-pad
|
||||
})
|
||||
.and("centered()")
|
||||
.and({
|
||||
// ANCHOR: centered
|
||||
content().centered()
|
||||
// ANCHOR_END: centered
|
||||
})
|
||||
.and("pad_by().align_right()")
|
||||
.and({
|
||||
// ANCHOR: align-right-pad
|
||||
content()
|
||||
.pad_by(Edges::default().with_right(Lp::inches(1)))
|
||||
.align_right()
|
||||
// ANCHOR_END: align-right-pad
|
||||
})
|
||||
.and("align_right()")
|
||||
.and({
|
||||
// ANCHOR: align-right
|
||||
content().align_right()
|
||||
// ANCHOR_END: align-right
|
||||
})
|
||||
.into_rows(),
|
||||
)
|
||||
.still_frame(|recorder| {
|
||||
const LEFT: u32 = 40;
|
||||
const PADDING: u32 = 96;
|
||||
const RIGHT: u32 = 710;
|
||||
const CENTER: u32 = 375;
|
||||
|
||||
let container_color = ThemePair::default().dark.surface.lowest_container;
|
||||
let primary = ThemePair::default().dark.primary.color;
|
||||
|
||||
recorder.assert_pixel_color(Point::new(LEFT, 35), container_color, "surface");
|
||||
|
||||
// Default fills the entire space
|
||||
recorder.assert_pixel_color(Point::new(LEFT, 70), primary, "default spacer");
|
||||
recorder.assert_pixel_color(Point::new(CENTER, 70), primary, "default spacer");
|
||||
recorder.assert_pixel_color(Point::new(RIGHT, 70), primary, "default spacer");
|
||||
|
||||
// align-left
|
||||
recorder.assert_pixel_color(Point::new(LEFT, 140), primary, "align-left spacer");
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(LEFT + PADDING, 140),
|
||||
container_color,
|
||||
"align-left empty",
|
||||
);
|
||||
|
||||
// align-left-pad
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(LEFT + PADDING, 215),
|
||||
primary,
|
||||
"align-left-pad spacer",
|
||||
);
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(LEFT, 215),
|
||||
container_color,
|
||||
"align-left-pad empty before",
|
||||
);
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(CENTER, 215),
|
||||
container_color,
|
||||
"align-left-pad empty after",
|
||||
);
|
||||
|
||||
// centered
|
||||
recorder.assert_pixel_color(Point::new(CENTER, 295), primary, "centered spacer");
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(LEFT + PADDING, 295),
|
||||
container_color,
|
||||
"centered empty before",
|
||||
);
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(RIGHT - PADDING, 295),
|
||||
container_color,
|
||||
"centered empty after",
|
||||
);
|
||||
|
||||
// align-right-pad
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(RIGHT - PADDING, 360),
|
||||
primary,
|
||||
"align-right-pad spacer",
|
||||
);
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(CENTER, 360),
|
||||
container_color,
|
||||
"align-right-pad empty before",
|
||||
);
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(RIGHT, 360),
|
||||
container_color,
|
||||
"align-right-pad empty after",
|
||||
);
|
||||
|
||||
// align-right
|
||||
recorder.assert_pixel_color(Point::new(RIGHT, 435), primary, "align-right spacer");
|
||||
recorder.assert_pixel_color(
|
||||
Point::new(RIGHT - PADDING, 435),
|
||||
container_color,
|
||||
"align-right empty",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs() {
|
||||
main();
|
||||
}
|
||||
69
guide/guide-examples/src/lib.rs
Normal file
69
guide/guide-examples/src/lib.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use std::panic::AssertUnwindSafe;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cushy::figures::units::Px;
|
||||
use cushy::figures::Size;
|
||||
use cushy::widget::MakeWidget;
|
||||
use cushy::widgets::container::ContainerShadow;
|
||||
use cushy::window::{Rgba8, VirtualRecorder, VirtualRecorderBuilder};
|
||||
|
||||
pub struct BookExample {
|
||||
name: &'static str,
|
||||
recorder: VirtualRecorderBuilder<Rgba8>,
|
||||
}
|
||||
|
||||
fn target_dir() -> PathBuf {
|
||||
let target_dir = std::env::current_dir()
|
||||
.expect("missing current dir")
|
||||
.parent()
|
||||
.expect("missing guide folder")
|
||||
.join("src")
|
||||
.join("examples");
|
||||
assert!(
|
||||
target_dir.is_dir(),
|
||||
"current directory is not guide-examples"
|
||||
);
|
||||
|
||||
target_dir
|
||||
}
|
||||
|
||||
impl BookExample {
|
||||
pub fn new(name: &'static str, interface: impl MakeWidget) -> Self {
|
||||
Self {
|
||||
name,
|
||||
recorder: interface
|
||||
.contain()
|
||||
.shadow(ContainerShadow::drop(Px::new(16), Px::new(32)))
|
||||
.width(Px::new(750))
|
||||
.build_recorder()
|
||||
.with_alpha()
|
||||
.resize_to_fit()
|
||||
.size(Size::new(750, 432)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn still_frame<Test>(self, test: Test)
|
||||
where
|
||||
Test: FnOnce(&mut VirtualRecorder<Rgba8>),
|
||||
{
|
||||
let mut recorder = self.recorder.finish().unwrap();
|
||||
|
||||
let capture = std::env::var("CAPTURE").is_ok();
|
||||
let errored = std::panic::catch_unwind(AssertUnwindSafe(|| test(&mut recorder))).is_err();
|
||||
if errored || capture {
|
||||
let path = target_dir().join(format!("{}.png", self.name));
|
||||
recorder.image().save(&path).expect("error saving file");
|
||||
println!("Wrote {}", path.display());
|
||||
|
||||
if errored {
|
||||
std::process::exit(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pub fn animated<Test>(self, test: Test)
|
||||
// where
|
||||
// Test: FnOnce(&mut AnimationRecorder<'_, Rgb8>),
|
||||
// {
|
||||
// }
|
||||
}
|
||||
3
guide/src/SUMMARY.md
Normal file
3
guide/src/SUMMARY.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Summary
|
||||
|
||||
- [Chapter 1](./chapter_1.md)
|
||||
33
guide/src/chapter_1.md
Normal file
33
guide/src/chapter_1.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Aligning Widgets
|
||||
|
||||

|
||||
|
||||
## Align a widget to the left
|
||||
|
||||
```rust,no_run,no_playground
|
||||
{{#include ../guide-examples/examples/align.rs:align-left}}
|
||||
```
|
||||
|
||||
## Align a widget to the left, with padding
|
||||
|
||||
```rust,no_run,no_playground
|
||||
{{#include ../guide-examples/examples/align.rs:align-left-pad}}
|
||||
```
|
||||
|
||||
## Align a widget to the center
|
||||
|
||||
```rust,no_run,no_playground
|
||||
{{#include ../guide-examples/examples/align.rs:centered}}
|
||||
```
|
||||
|
||||
## Align a widget to the right, with padding
|
||||
|
||||
```rust,no_run,no_playground
|
||||
{{#include ../guide-examples/examples/align.rs:align-right-pad}}
|
||||
```
|
||||
|
||||
## Align a widget to the right
|
||||
|
||||
```rust,no_run,no_playground
|
||||
{{#include ../guide-examples/examples/align.rs:align-right}}
|
||||
```
|
||||
BIN
guide/src/examples/align-horizontal.png
Normal file
BIN
guide/src/examples/align-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
|
|
@ -1742,7 +1742,7 @@ impl FixedTheme {
|
|||
///
|
||||
/// The goal of this type is to allow various tones of a given hue/saturation to
|
||||
/// be generated easily.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ColorSource {
|
||||
/// A measurement of hue, in degees, from -180 to 180.
|
||||
///
|
||||
|
|
@ -1760,6 +1760,13 @@ pub struct ColorSource {
|
|||
pub saturation: ZeroToOne,
|
||||
}
|
||||
|
||||
impl PartialEq for ColorSource {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(self.hue.into_degrees() - other.hue.into_degrees()).abs() < f32::EPSILON
|
||||
&& self.saturation == other.saturation
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorSource {
|
||||
/// Returns a new source with the given hue (in degrees) and saturation (0.0
|
||||
/// - 1.0).
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ use figures::Size;
|
|||
use kludgine::Color;
|
||||
|
||||
use crate::context::{GraphicsContext, LayoutContext};
|
||||
use crate::styles::components::PrimaryColor;
|
||||
use crate::styles::{DynamicComponent, IntoDynamicComponentValue};
|
||||
use crate::value::{IntoValue, Value};
|
||||
use crate::widget::Widget;
|
||||
use crate::ConstraintLimit;
|
||||
|
|
@ -10,7 +12,7 @@ use crate::ConstraintLimit;
|
|||
/// A widget that occupies space, optionally filling it with a color.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Space {
|
||||
color: Value<Color>,
|
||||
color: Value<ColorSource>,
|
||||
}
|
||||
|
||||
impl Default for Space {
|
||||
|
|
@ -24,7 +26,7 @@ impl Space {
|
|||
#[must_use]
|
||||
pub const fn clear() -> Self {
|
||||
Self {
|
||||
color: Value::Constant(Color::CLEAR_BLACK),
|
||||
color: Value::Constant(ColorSource::Color(Color::CLEAR_BLACK)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,14 +34,38 @@ impl Space {
|
|||
#[must_use]
|
||||
pub fn colored(color: impl IntoValue<Color>) -> Self {
|
||||
Self {
|
||||
color: color.into_value(),
|
||||
color: color
|
||||
.into_value()
|
||||
.map_each(|color| ColorSource::Color(*color)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a spacer that fills itself with `dynamic`'s color.
|
||||
pub fn dynamic(dynamic: impl IntoDynamicComponentValue) -> Self {
|
||||
Self {
|
||||
color: dynamic
|
||||
.into_dynamic_component()
|
||||
.map_each(|component| ColorSource::Dynamic(component.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a spacer that fills itself with the value of [`PrimaryColor`].
|
||||
#[must_use]
|
||||
pub fn primary() -> Self {
|
||||
Self::dynamic(PrimaryColor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Space {
|
||||
fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) {
|
||||
let color = self.color.get_tracking_redraw(context);
|
||||
let source = self.color.get_tracking_redraw(context);
|
||||
let color = match source {
|
||||
ColorSource::Color(color) => color,
|
||||
ColorSource::Dynamic(component) => component
|
||||
.resolve(context)
|
||||
.and_then(|component| Color::try_from(component).ok())
|
||||
.unwrap_or(Color::CLEAR_BLACK),
|
||||
};
|
||||
context.fill(color);
|
||||
}
|
||||
|
||||
|
|
@ -51,3 +77,9 @@ impl Widget for Space {
|
|||
Size::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
enum ColorSource {
|
||||
Color(Color),
|
||||
Dynamic(DynamicComponent),
|
||||
}
|
||||
|
|
|
|||
212
src/window.rs
212
src/window.rs
|
|
@ -20,6 +20,7 @@ use figures::units::{Px, UPx};
|
|||
use figures::{
|
||||
Fraction, IntoSigned, IntoUnsigned, Point, Ranged, Rect, Round, ScreenScale, Size, Zero,
|
||||
};
|
||||
use image::{DynamicImage, RgbImage, RgbaImage};
|
||||
use intentional::{Assert, Cast};
|
||||
use kludgine::app::winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||
use kludgine::app::winit::event::{
|
||||
|
|
@ -728,6 +729,7 @@ pub trait WindowBehavior: Sized + 'static {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
struct CushyWindow<T> {
|
||||
behavior: T,
|
||||
tree: Tree,
|
||||
|
|
@ -745,6 +747,7 @@ struct CushyWindow<T> {
|
|||
keyboard_activated: Option<WidgetId>,
|
||||
min_inner_size: Option<Size<UPx>>,
|
||||
max_inner_size: Option<Size<UPx>>,
|
||||
resize_to_fit: bool,
|
||||
theme: Option<DynamicReader<ThemePair>>,
|
||||
current_theme: ThemePair,
|
||||
theme_mode: Value<ThemeMode>,
|
||||
|
|
@ -1146,6 +1149,7 @@ where
|
|||
keyboard_activated: None,
|
||||
min_inner_size: None,
|
||||
max_inner_size: None,
|
||||
resize_to_fit: false,
|
||||
current_theme,
|
||||
theme,
|
||||
theme_mode,
|
||||
|
|
@ -1172,7 +1176,7 @@ where
|
|||
self.tree
|
||||
.new_frame(self.redraw_status.invalidations().drain());
|
||||
|
||||
let resizable = window.is_resizable();
|
||||
let resizable = window.is_resizable() || self.resize_to_fit;
|
||||
let mut window = RunningWindow::new(
|
||||
window,
|
||||
graphics.id(),
|
||||
|
|
@ -1237,6 +1241,8 @@ where
|
|||
new_size = new_size.min(max_size);
|
||||
}
|
||||
layout_context.request_inner_size(new_size);
|
||||
} else if self.resize_to_fit && window_size != layout_size {
|
||||
layout_context.request_inner_size(layout_size);
|
||||
}
|
||||
self.root.set_layout(Rect::from(render_size.into_signed()));
|
||||
|
||||
|
|
@ -1895,7 +1901,9 @@ pub(crate) mod sealed {
|
|||
use std::cell::RefCell;
|
||||
|
||||
use figures::units::UPx;
|
||||
use figures::Size;
|
||||
use figures::{Point, Size};
|
||||
use image::DynamicImage;
|
||||
use kludgine::Color;
|
||||
|
||||
use crate::app::Cushy;
|
||||
use crate::context::sealed::InvalidationStatus;
|
||||
|
|
@ -1940,6 +1948,8 @@ pub(crate) mod sealed {
|
|||
const HAS_ALPHA: bool;
|
||||
|
||||
fn convert_rgba(data: &mut Vec<u8>, width: u32, bytes_per_row: u32);
|
||||
fn load_image(data: &[u8], size: Size<UPx>) -> DynamicImage;
|
||||
fn pixel_color(location: Point<UPx>, data: &[u8], size: Size<UPx>) -> Color;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2708,6 +2718,31 @@ impl sealed::CaptureFormat for Rgb8 {
|
|||
retain
|
||||
});
|
||||
}
|
||||
|
||||
fn load_image(data: &[u8], size: Size<UPx>) -> DynamicImage {
|
||||
DynamicImage::ImageRgb8(
|
||||
RgbImage::from_vec(size.width.get(), size.height.get(), data.to_vec())
|
||||
.expect("incorrect dimensions"),
|
||||
)
|
||||
}
|
||||
|
||||
fn pixel_color(location: Point<UPx>, data: &[u8], size: Size<UPx>) -> Color {
|
||||
let pixel_offset = pixel_offset(data, location, size, 3);
|
||||
Color::new(pixel_offset[0], pixel_offset[1], pixel_offset[2], 255)
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel_offset(
|
||||
data: &[u8],
|
||||
location: Point<UPx>,
|
||||
size: Size<UPx>,
|
||||
bytes_per_component: u32,
|
||||
) -> &[u8] {
|
||||
assert!(location.x < size.width && location.y < size.height);
|
||||
|
||||
let width = size.width.get();
|
||||
let index = location.y.get() * width + location.x.get();
|
||||
&data[usize::try_from(index * bytes_per_component).expect("offset out of bounds")..]
|
||||
}
|
||||
|
||||
impl CaptureFormat for Rgba8 {}
|
||||
|
|
@ -2727,6 +2762,23 @@ impl sealed::CaptureFormat for Rgba8 {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn load_image(data: &[u8], size: Size<UPx>) -> DynamicImage {
|
||||
DynamicImage::ImageRgba8(
|
||||
RgbaImage::from_vec(size.width.get(), size.height.get(), data.to_vec())
|
||||
.expect("incorrect dimensions"),
|
||||
)
|
||||
}
|
||||
|
||||
fn pixel_color(location: Point<UPx>, data: &[u8], size: Size<UPx>) -> Color {
|
||||
let pixel_offset = pixel_offset(data, location, size, 4);
|
||||
Color::new(
|
||||
pixel_offset[0],
|
||||
pixel_offset[1],
|
||||
pixel_offset[2],
|
||||
pixel_offset[3],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A builder of a [`VirtualRecorder`].
|
||||
|
|
@ -2735,6 +2787,7 @@ pub struct VirtualRecorderBuilder<Format> {
|
|||
size: Size<UPx>,
|
||||
scale: f32,
|
||||
format: PhantomData<Format>,
|
||||
resize_to_fit: bool,
|
||||
}
|
||||
|
||||
impl VirtualRecorderBuilder<Rgb8> {
|
||||
|
|
@ -2745,6 +2798,7 @@ impl VirtualRecorderBuilder<Rgb8> {
|
|||
size: Size::new(UPx::new(800), UPx::new(600)),
|
||||
scale: 1.0,
|
||||
format: PhantomData,
|
||||
resize_to_fit: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2756,6 +2810,7 @@ impl VirtualRecorderBuilder<Rgb8> {
|
|||
contents: self.contents,
|
||||
size: self.size,
|
||||
scale: self.scale,
|
||||
resize_to_fit: self.resize_to_fit,
|
||||
format: PhantomData,
|
||||
}
|
||||
}
|
||||
|
|
@ -2787,9 +2842,17 @@ where
|
|||
self
|
||||
}
|
||||
|
||||
/// Sets this virtual recorder to allow updating its size based on the
|
||||
/// contents being rendered.
|
||||
#[must_use]
|
||||
pub fn resize_to_fit(mut self) -> Self {
|
||||
self.resize_to_fit = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns an initialized [`VirtualRecorder`].
|
||||
pub fn finish(self) -> Result<VirtualRecorder<Format>, VirtualRecorderError> {
|
||||
VirtualRecorder::new(self.size, self.scale, self.contents)
|
||||
VirtualRecorder::new(self.size, self.scale, self.resize_to_fit, self.contents)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2864,7 +2927,9 @@ pub struct VirtualRecorder<Format = Rgb8> {
|
|||
queue: Arc<wgpu::Queue>,
|
||||
capture: Option<Box<Capture>>,
|
||||
data: Vec<u8>,
|
||||
data_size: Size<UPx>,
|
||||
cursor: Dynamic<Point<Px>>,
|
||||
cursor_visible: bool,
|
||||
cursor_graphic: Drawing,
|
||||
format: PhantomData<Format>,
|
||||
}
|
||||
|
|
@ -2881,6 +2946,7 @@ where
|
|||
pub fn new(
|
||||
size: Size<UPx>,
|
||||
scale: f32,
|
||||
resize_to_fit: bool,
|
||||
contents: impl MakeWidget,
|
||||
) -> Result<Self, VirtualRecorderError> {
|
||||
let wgpu = wgpu::Instance::default();
|
||||
|
|
@ -2912,11 +2978,18 @@ where
|
|||
queue: Arc::new(queue),
|
||||
cursor: Dynamic::default(),
|
||||
cursor_graphic: Drawing::default(),
|
||||
cursor_visible: false,
|
||||
capture: None,
|
||||
data: Vec::new(),
|
||||
data_size: Size::ZERO,
|
||||
format: PhantomData,
|
||||
};
|
||||
recorder.window.window.resize_to_fit = resize_to_fit;
|
||||
recorder.refresh()?;
|
||||
|
||||
if resize_to_fit && recorder.window.state.size != recorder.window.size() {
|
||||
recorder.refresh()?;
|
||||
}
|
||||
Ok(recorder)
|
||||
}
|
||||
|
||||
|
|
@ -2927,6 +3000,52 @@ where
|
|||
&self.data
|
||||
}
|
||||
|
||||
/// Returns the color of the pixel at `location`.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This function will panic if location is outside of the bounds of the
|
||||
/// captured image. When the window's size has been changed, this function
|
||||
/// operates on the size of the window when the last call to
|
||||
/// [`Self::refresh()`] was made.
|
||||
pub fn pixel_color<Unit>(&self, location: Point<Unit>) -> Color
|
||||
where
|
||||
Unit: Into<UPx>,
|
||||
{
|
||||
Format::pixel_color(location.map(Into::into), self.bytes(), self.data_size)
|
||||
}
|
||||
|
||||
/// Asserts that the color of the pixel at `location` is `expected`.
|
||||
///
|
||||
/// This function allows for slight color variations. This is because of how
|
||||
/// colorspace corrections can lead to rounding errors.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// This function panics if the color is not the expected color.
|
||||
pub fn assert_pixel_color<Unit>(&self, location: Point<Unit>, expected: Color, component: &str)
|
||||
where
|
||||
Unit: Into<UPx>,
|
||||
{
|
||||
let location = location.map(Into::into);
|
||||
let color = self.pixel_color(location);
|
||||
let max_delta = color
|
||||
.red()
|
||||
.abs_diff(expected.red())
|
||||
.max(color.green().abs_diff(expected.green()))
|
||||
.max(color.blue().abs_diff(expected.blue()))
|
||||
.max(color.alpha().abs_diff(expected.alpha()));
|
||||
assert!(
|
||||
max_delta <= 1,
|
||||
"assertion failed: {component} at {location:?} was {color:?}, not {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the current contents as an image.
|
||||
pub fn image(&self) -> DynamicImage {
|
||||
Format::load_image(self.bytes(), self.data_size)
|
||||
}
|
||||
|
||||
fn recreate_buffers_if_needed(&mut self, size: Size<UPx>, bytes: u64, bytes_per_row: u32) {
|
||||
if self
|
||||
.capture
|
||||
|
|
@ -2967,20 +3086,28 @@ where
|
|||
}
|
||||
|
||||
fn redraw(&mut self) {
|
||||
let render_size = self.window.kludgine.size().ceil();
|
||||
let mut render_size = self.window.kludgine.size().ceil();
|
||||
if self.window.state.size != render_size {
|
||||
let current_scale = self.window.scale();
|
||||
self.window
|
||||
.resize(self.window.state.size, current_scale, &self.queue);
|
||||
render_size = self.window.state.size;
|
||||
}
|
||||
let bytes_per_row = copy_buffer_aligned_bytes_per_row(render_size.width.get() * 4);
|
||||
let size = u64::from(bytes_per_row) * u64::from(render_size.height.get());
|
||||
self.recreate_buffers_if_needed(render_size, size, bytes_per_row);
|
||||
|
||||
let capture = self.capture.as_ref().assert("always initialized above");
|
||||
|
||||
let mut gfx = self.window.graphics(&self.device, &self.queue);
|
||||
let mut frame = self.cursor_graphic.new_frame(&mut gfx);
|
||||
frame.draw_shape(
|
||||
Shape::filled_circle(Px::new(4), Color::WHITE, Origin::Center)
|
||||
.translate_by(self.cursor.get()),
|
||||
);
|
||||
drop(frame);
|
||||
if self.cursor_visible {
|
||||
let mut gfx = self.window.graphics(&self.device, &self.queue);
|
||||
let mut frame = self.cursor_graphic.new_frame(&mut gfx);
|
||||
frame.draw_shape(
|
||||
Shape::filled_circle(Px::new(4), Color::WHITE, Origin::Center)
|
||||
.translate_by(self.cursor.get()),
|
||||
);
|
||||
drop(frame);
|
||||
}
|
||||
|
||||
self.window.prepare(&self.device, &self.queue);
|
||||
|
||||
|
|
@ -3001,7 +3128,7 @@ where
|
|||
},
|
||||
&self.device,
|
||||
&self.queue,
|
||||
Some(&self.cursor_graphic),
|
||||
self.cursor_visible.then_some(&self.cursor_graphic),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3012,6 +3139,7 @@ where
|
|||
let capture = self.capture.as_ref().assert("always initialized above");
|
||||
|
||||
capture.map_into::<Format>(&mut self.data, &self.device, &self.queue)?;
|
||||
self.data_size = capture.texture.size();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -3021,11 +3149,28 @@ where
|
|||
self.cursor.set(position);
|
||||
}
|
||||
|
||||
/// Enables or disables drawing of the virtual cursor.
|
||||
pub fn set_cursor_visible(&mut self, visible: bool) {
|
||||
self.cursor_visible = visible;
|
||||
}
|
||||
|
||||
/// Begins recording an animated png.
|
||||
pub fn record_animated_png(&mut self, target_fps: u8) -> AnimationRecorder<'_, Format> {
|
||||
AnimationRecorder {
|
||||
target_fps,
|
||||
assembler: FrameAssembler::spawn::<Format>(self.device.clone(), self.queue.clone()),
|
||||
assembler: Some(FrameAssembler::spawn::<Format>(
|
||||
self.device.clone(),
|
||||
self.queue.clone(),
|
||||
)),
|
||||
recorder: self,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a recorder that does not store any rendered frames.
|
||||
pub fn simulate_animation(&mut self) -> AnimationRecorder<'_, Format> {
|
||||
AnimationRecorder {
|
||||
target_fps: 0,
|
||||
assembler: None,
|
||||
recorder: self,
|
||||
}
|
||||
}
|
||||
|
|
@ -3040,7 +3185,7 @@ fn copy_buffer_aligned_bytes_per_row(width: u32) -> u32 {
|
|||
pub struct AnimationRecorder<'a, Format> {
|
||||
recorder: &'a mut VirtualRecorder<Format>,
|
||||
target_fps: u8,
|
||||
assembler: FrameAssembler,
|
||||
assembler: Option<FrameAssembler>,
|
||||
}
|
||||
|
||||
impl<Format> AnimationRecorder<'_, Format>
|
||||
|
|
@ -3070,6 +3215,10 @@ where
|
|||
|
||||
/// Waits until `time`, rendering frames as needed.
|
||||
pub fn wait_until(&mut self, time: Instant) -> Result<(), VirtualRecorderError> {
|
||||
let Some(assembler) = self.assembler.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let frame_duration = Duration::from_micros(1_000_000 / u64::from(self.target_fps));
|
||||
let mut last_frame = Instant::now();
|
||||
|
||||
|
|
@ -3090,19 +3239,19 @@ where
|
|||
if final_frame || next_frame == now {
|
||||
// Try to reuse an existing capture instead of forcing an
|
||||
// allocation.
|
||||
if let Ok(capture) = self.assembler.resuable_captures.try_recv() {
|
||||
if let Ok(capture) = assembler.resuable_captures.try_recv() {
|
||||
self.recorder.capture = Some(capture);
|
||||
}
|
||||
let elapsed = now.saturating_duration_since(last_frame);
|
||||
last_frame = now;
|
||||
self.recorder.redraw();
|
||||
let capture = self.recorder.capture.take().assert("always present");
|
||||
if self.assembler.sender.send((capture, elapsed)).is_err() {
|
||||
if assembler.sender.send((capture, elapsed)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if now > time {
|
||||
if final_frame {
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -3114,8 +3263,13 @@ where
|
|||
}
|
||||
|
||||
/// Encodes the currently recorded frames into a new file at `path`.
|
||||
///
|
||||
/// If this animation was created from
|
||||
/// [`VirtualRecorder::simulate_animation`], this function will do nothing.
|
||||
pub fn write_to(self, path: impl AsRef<Path>) -> Result<(), VirtualRecorderError> {
|
||||
let frames = self.assembler.finish()?;
|
||||
let Some(frames) = self.assembler.map(FrameAssembler::finish).transpose()? else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
|
|
@ -3131,28 +3285,20 @@ where
|
|||
encoder.set_animated(u32::try_from(frames.len()).assert("too many frames"), 0)?;
|
||||
encoder.set_compression(png::Compression::Best);
|
||||
|
||||
let mut current_frame_delay = frames.first().assert("always at least one frame").duration;
|
||||
encoder.set_frame_delay(
|
||||
current_frame_delay
|
||||
.as_millis()
|
||||
// TODO should be checked
|
||||
.cast(),
|
||||
1_000,
|
||||
)?;
|
||||
let mut current_frame_delay = Duration::ZERO;
|
||||
let mut writer = encoder.write_header()?;
|
||||
for frame in &frames {
|
||||
writer.write_image_data(&frame.data)?;
|
||||
if current_frame_delay != frame.duration {
|
||||
current_frame_delay = frame.duration;
|
||||
// This has a limitation that a single frame can't be longer
|
||||
// than ~6.5 seconds, but it ensures frame timing is more
|
||||
// accurate.
|
||||
writer.set_frame_delay(
|
||||
current_frame_delay
|
||||
.as_millis()
|
||||
// TODO should be checked
|
||||
.cast(),
|
||||
1_000,
|
||||
u16::try_from(current_frame_delay.as_nanos() / 100_000).unwrap_or(u16::MAX),
|
||||
10_000,
|
||||
)?;
|
||||
}
|
||||
|
||||
writer.write_image_data(&frame.data)?;
|
||||
}
|
||||
|
||||
writer.finish()?;
|
||||
|
|
|
|||
Loading…
Reference in a new issue