mirror of
https://github.com/mfontanini/presenterm.git
synced 2025-05-05 15:32:58 +00:00
perf: pre scale generated images (#553)
Before this change only filesystem images were scaled when transitioning between slides. This PR does that for generated images as well, and polls other slides while doing transitions so any images generated asynchronously are already ASCII'd before we transition into them. This PR has shown that the async render code has reached proper 💩 level and requires changing it, more so since #537 will be implemented soon-ish.
This commit is contained in:
commit
f59c19af36
@ -97,13 +97,14 @@ impl ContentManager {
|
||||
ImageSource::Filesystem(path) => Ok(path),
|
||||
ImageSource::Generated => {
|
||||
let mut buffer = Vec::new();
|
||||
let dimensions = image.dimensions();
|
||||
let TerminalImage::Ascii(resource) = image.image.as_ref() else { panic!("not in ascii mode") };
|
||||
let dimensions = image.image().dimensions();
|
||||
let TerminalImage::Ascii(image) = image.image() else { panic!("not in ascii mode") };
|
||||
let image = image.image();
|
||||
PngEncoder::new(&mut buffer).write_image(
|
||||
resource.as_bytes(),
|
||||
image.as_bytes(),
|
||||
dimensions.0,
|
||||
dimensions.1,
|
||||
resource.color().into(),
|
||||
image.color().into(),
|
||||
)?;
|
||||
let name = format!("img-{}.png", self.image_count);
|
||||
let path = self.output_directory.path().join(name);
|
||||
|
@ -173,8 +173,8 @@ impl Presentation {
|
||||
}
|
||||
|
||||
/// Poll every async render operation in the current slide and check whether they're completed.
|
||||
pub(crate) fn poll_slide_async_renders(&mut self) -> RenderAsyncState {
|
||||
let slide = self.current_slide_mut();
|
||||
pub(crate) fn poll_slide_async_renders(&mut self, slide: usize) -> RenderAsyncState {
|
||||
let slide = &mut self.slides[slide];
|
||||
let mut slide_state = RenderAsyncState::Rendered;
|
||||
for operation in slide.iter_operations_mut() {
|
||||
if let RenderOperation::RenderAsync(operation) = operation {
|
||||
|
@ -156,12 +156,12 @@ impl<'a> Presenter<'a> {
|
||||
self.try_scale_transition_images()?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::NextSlide => {
|
||||
self.next_slide(&mut drawer)?;
|
||||
CommandSideEffect::AnimateNextSlide => {
|
||||
self.animate_next_slide(&mut drawer)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::PreviousSlide => {
|
||||
self.previous_slide(&mut drawer)?;
|
||||
CommandSideEffect::AnimatePreviousSlide => {
|
||||
self.animate_previous_slide(&mut drawer)?;
|
||||
break;
|
||||
}
|
||||
CommandSideEffect::None => (),
|
||||
@ -203,15 +203,19 @@ impl<'a> Presenter<'a> {
|
||||
return Ok(false);
|
||||
}
|
||||
let current_index = self.state.presentation().current_slide_index();
|
||||
if self.slides_with_pending_async_renders.contains(¤t_index) {
|
||||
let state = self.state.presentation_mut().poll_slide_async_renders();
|
||||
self.poll_slide_async_renders(current_index)
|
||||
}
|
||||
|
||||
fn poll_slide_async_renders(&mut self, slide: usize) -> Result<bool, RenderError> {
|
||||
if self.slides_with_pending_async_renders.contains(&slide) {
|
||||
let state = self.state.presentation_mut().poll_slide_async_renders(slide);
|
||||
match state {
|
||||
RenderAsyncState::NotStarted | RenderAsyncState::Rendering { modified: false } => (),
|
||||
RenderAsyncState::Rendering { modified: true } => {
|
||||
return Ok(true);
|
||||
}
|
||||
RenderAsyncState::Rendered | RenderAsyncState::JustFinishedRendering => {
|
||||
self.slides_with_pending_async_renders.remove(¤t_index);
|
||||
self.slides_with_pending_async_renders.remove(&slide);
|
||||
return Ok(true);
|
||||
}
|
||||
};
|
||||
@ -279,7 +283,7 @@ impl<'a> Presenter<'a> {
|
||||
if !presentation.jump_next() {
|
||||
false
|
||||
} else if presentation.current_slide_index() != current_slide {
|
||||
return CommandSideEffect::NextSlide;
|
||||
return CommandSideEffect::AnimateNextSlide;
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@ -290,7 +294,7 @@ impl<'a> Presenter<'a> {
|
||||
if !presentation.jump_previous() {
|
||||
false
|
||||
} else if presentation.current_slide_index() != current_slide {
|
||||
return CommandSideEffect::PreviousSlide;
|
||||
return CommandSideEffect::AnimatePreviousSlide;
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@ -361,7 +365,7 @@ impl<'a> Presenter<'a> {
|
||||
return Ok(());
|
||||
}
|
||||
let options = RenderEngineOptions { max_size: self.options.max_size.clone(), ..Default::default() };
|
||||
let scaler = AsciiScaler::new(options, self.resources.image_registry());
|
||||
let scaler = AsciiScaler::new(options);
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
scaler.process(self.state.presentation(), &dimensions)?;
|
||||
Ok(())
|
||||
@ -436,34 +440,40 @@ impl<'a> Presenter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
fn animate_next_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
let Some(config) = self.options.transition.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let registry = self.resources.image_registry();
|
||||
self.poll_and_scale_images()?;
|
||||
|
||||
let options = drawer.render_engine_options();
|
||||
let presentation = self.state.presentation_mut();
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
presentation.jump_previous();
|
||||
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options, registry.clone())?;
|
||||
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
presentation.jump_next();
|
||||
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options, registry)?;
|
||||
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
let direction = TransitionDirection::Next;
|
||||
self.animate_transition(drawer, left, right, direction, dimensions, config)
|
||||
}
|
||||
|
||||
fn previous_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
fn animate_previous_slide(&mut self, drawer: &mut TerminalDrawer) -> RenderResult {
|
||||
let Some(config) = self.options.transition.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let registry = self.resources.image_registry();
|
||||
self.poll_and_scale_images()?;
|
||||
|
||||
let options = drawer.render_engine_options();
|
||||
let presentation = self.state.presentation_mut();
|
||||
let dimensions = WindowSize::current(self.options.font_size_fallback)?;
|
||||
presentation.jump_next();
|
||||
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options, registry.clone())?;
|
||||
|
||||
// Re-borrow to avoid calling fns above while mutably borrowing
|
||||
let presentation = self.state.presentation_mut();
|
||||
|
||||
let right = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
presentation.jump_previous();
|
||||
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options, registry)?;
|
||||
let left = Self::virtual_render(presentation.current_slide(), dimensions.clone(), &options)?;
|
||||
let direction = TransitionDirection::Previous;
|
||||
self.animate_transition(drawer, left, right, direction, dimensions, config)
|
||||
}
|
||||
@ -543,13 +553,23 @@ impl<'a> Presenter<'a> {
|
||||
slide: &Slide,
|
||||
dimensions: WindowSize,
|
||||
options: &RenderEngineOptions,
|
||||
registry: ImageRegistry,
|
||||
) -> Result<TerminalGrid, RenderError> {
|
||||
let mut term = VirtualTerminal::new(dimensions.clone(), ImageBehavior::PrintAscii(registry));
|
||||
let mut term = VirtualTerminal::new(dimensions.clone(), ImageBehavior::PrintAscii);
|
||||
let engine = RenderEngine::new(&mut term, dimensions.clone(), options.clone());
|
||||
engine.render(slide.iter_visible_operations())?;
|
||||
Ok(term.into_contents())
|
||||
}
|
||||
|
||||
fn poll_and_scale_images(&mut self) -> RenderResult {
|
||||
let mut needs_scaling = false;
|
||||
for index in 0..self.state.presentation().iter_slides().count() {
|
||||
needs_scaling = self.poll_slide_async_renders(index)? || needs_scaling;
|
||||
}
|
||||
if needs_scaling {
|
||||
self.try_scale_transition_images()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
enum CommandSideEffect {
|
||||
@ -557,8 +577,8 @@ enum CommandSideEffect {
|
||||
Suspend,
|
||||
Redraw,
|
||||
Reload,
|
||||
NextSlide,
|
||||
PreviousSlide,
|
||||
AnimateNextSlide,
|
||||
AnimatePreviousSlide,
|
||||
None,
|
||||
}
|
||||
|
||||
|
@ -3,10 +3,10 @@ use super::{
|
||||
engine::{RenderEngine, RenderEngineOptions},
|
||||
};
|
||||
use crate::{
|
||||
ImageRegistry, WindowSize,
|
||||
WindowSize,
|
||||
presentation::Presentation,
|
||||
terminal::{
|
||||
image::{Image, ImageSource},
|
||||
image::Image,
|
||||
printer::{TerminalCommand, TerminalError, TerminalIo},
|
||||
},
|
||||
};
|
||||
@ -15,12 +15,11 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub(crate) struct AsciiScaler {
|
||||
options: RenderEngineOptions,
|
||||
registry: ImageRegistry,
|
||||
}
|
||||
|
||||
impl AsciiScaler {
|
||||
pub(crate) fn new(options: RenderEngineOptions, registry: ImageRegistry) -> Self {
|
||||
Self { options, registry }
|
||||
pub(crate) fn new(options: RenderEngineOptions) -> Self {
|
||||
Self { options }
|
||||
}
|
||||
|
||||
pub(crate) fn process(self, presentation: &Presentation, dimensions: &WindowSize) -> Result<(), RenderError> {
|
||||
@ -29,13 +28,13 @@ impl AsciiScaler {
|
||||
let engine = RenderEngine::new(&mut collector, dimensions.clone(), self.options.clone());
|
||||
engine.render(slide.iter_operations())?;
|
||||
}
|
||||
thread::spawn(move || Self::scale(collector.images, self.registry));
|
||||
thread::spawn(move || Self::scale(collector.images));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scale(images: Vec<ScalableImage>, registry: ImageRegistry) {
|
||||
fn scale(images: Vec<ScalableImage>) {
|
||||
for image in images {
|
||||
let ascii_image = registry.as_ascii(&image.image);
|
||||
let ascii_image = image.image.to_ascii();
|
||||
ascii_image.cache_scaling(image.columns, image.rows);
|
||||
}
|
||||
}
|
||||
@ -84,11 +83,8 @@ impl TerminalIo for ImageCollector {
|
||||
}
|
||||
PrintImage { image, options } => {
|
||||
// we can only really cache filesystem images for now
|
||||
if matches!(image.source, ImageSource::Filesystem(_)) {
|
||||
let image =
|
||||
ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns };
|
||||
self.images.push(image);
|
||||
}
|
||||
let image = ScalableImage { image: image.clone(), rows: options.rows * 2, columns: options.columns };
|
||||
self.images.push(image);
|
||||
}
|
||||
ClearScreen => {
|
||||
self.current_column = 0;
|
||||
|
@ -258,7 +258,7 @@ where
|
||||
let starting_cursor =
|
||||
CursorPosition { row: starting_row.saturating_sub(rect.start_row), column: rect.start_column };
|
||||
|
||||
let (width, height) = image.dimensions();
|
||||
let (width, height) = image.image().dimensions();
|
||||
let (columns, rows) = match properties.size {
|
||||
ImageSize::ShrinkIfNeeded => {
|
||||
let image_scale =
|
||||
|
@ -133,10 +133,6 @@ impl Resources {
|
||||
inner.image_registry.clear();
|
||||
inner.themes.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn image_registry(&self) -> ImageRegistry {
|
||||
self.inner.borrow().image_registry.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches for file changes.
|
||||
|
@ -1,23 +1,59 @@
|
||||
use image::DynamicImage;
|
||||
use protocols::ascii::AsciiImage;
|
||||
|
||||
use self::printer::{ImageProperties, TerminalImage};
|
||||
use std::{fmt::Debug, ops::Deref, path::PathBuf, sync::Arc};
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
ops::Deref,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
pub(crate) mod printer;
|
||||
pub(crate) mod protocols;
|
||||
pub(crate) mod scale;
|
||||
|
||||
struct Inner {
|
||||
image: TerminalImage,
|
||||
ascii_image: Mutex<Option<AsciiImage>>,
|
||||
}
|
||||
|
||||
/// An image.
|
||||
///
|
||||
/// This stores the image in an [std::sync::Arc] so it's cheap to clone.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Image {
|
||||
pub(crate) image: Arc<TerminalImage>,
|
||||
inner: Arc<Inner>,
|
||||
pub(crate) source: ImageSource,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Constructs a new image.
|
||||
pub(crate) fn new(image: TerminalImage, source: ImageSource) -> Self {
|
||||
Self { image: Arc::new(image), source }
|
||||
let inner = Inner { image, ascii_image: Default::default() };
|
||||
Self { inner: Arc::new(inner), source }
|
||||
}
|
||||
|
||||
pub(crate) fn to_ascii(&self) -> AsciiImage {
|
||||
let mut ascii_image = self.inner.ascii_image.lock().unwrap();
|
||||
match ascii_image.deref() {
|
||||
Some(image) => image.clone(),
|
||||
None => {
|
||||
let image = match &self.inner.image {
|
||||
TerminalImage::Ascii(image) => image.clone(),
|
||||
TerminalImage::Kitty(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
TerminalImage::Iterm(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
#[cfg(feature = "sixel")]
|
||||
TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
};
|
||||
*ascii_image = Some(image.clone());
|
||||
image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn image(&self) -> &TerminalImage {
|
||||
&self.inner.image
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,19 +65,11 @@ impl PartialEq for Image {
|
||||
|
||||
impl Debug for Image {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let (width, height) = self.image.dimensions();
|
||||
let (width, height) = self.inner.image.dimensions();
|
||||
write!(f, "Image<{width}x{height}>")
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Image {
|
||||
type Target = TerminalImage;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.image
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum ImageSource {
|
||||
Filesystem(PathBuf),
|
||||
|
@ -18,7 +18,6 @@ use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
fmt, io,
|
||||
ops::Deref,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
@ -151,12 +150,11 @@ impl PrintImage for ImagePrinter {
|
||||
pub(crate) struct ImageRegistry {
|
||||
printer: Arc<ImagePrinter>,
|
||||
images: Arc<Mutex<HashMap<PathBuf, Image>>>,
|
||||
ascii_images: Arc<Mutex<HashMap<PathBuf, AsciiImage>>>,
|
||||
}
|
||||
|
||||
impl ImageRegistry {
|
||||
pub fn new(printer: Arc<ImagePrinter>) -> Self {
|
||||
Self { printer, images: Default::default(), ascii_images: Default::default() }
|
||||
Self { printer, images: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@ -191,39 +189,12 @@ impl ImageRegistry {
|
||||
let image = Image::new(resource, source);
|
||||
if let Some(key) = cache_key {
|
||||
images.insert(key.clone(), image.clone());
|
||||
drop(images);
|
||||
if let TerminalImage::Ascii(image) = image.image.as_ref() {
|
||||
self.ascii_images.lock().unwrap().insert(key, image.clone());
|
||||
}
|
||||
}
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {
|
||||
self.images.lock().unwrap().clear();
|
||||
self.ascii_images.lock().unwrap().clear();
|
||||
}
|
||||
|
||||
pub(crate) fn as_ascii(&self, image: &Image) -> AsciiImage {
|
||||
if let ImageSource::Filesystem(path) = &image.source {
|
||||
if let Some(image) = self.ascii_images.lock().unwrap().get(path) {
|
||||
return image.clone();
|
||||
}
|
||||
if let Some(TerminalImage::Ascii(image)) = self.images.lock().unwrap().get(path).map(|i| i.image.as_ref()) {
|
||||
return image.clone();
|
||||
}
|
||||
}
|
||||
let ascii_image = match image.image.deref() {
|
||||
TerminalImage::Ascii(image) => image.clone(),
|
||||
TerminalImage::Kitty(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
TerminalImage::Iterm(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
#[cfg(feature = "sixel")]
|
||||
TerminalImage::Sixel(image) => DynamicImage::from(image.as_rgba8()).into(),
|
||||
};
|
||||
if let ImageSource::Filesystem(path) = &image.source {
|
||||
self.ascii_images.lock().unwrap().insert(path.clone(), ascii_image.clone());
|
||||
}
|
||||
ascii_image
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,49 +10,49 @@ use itertools::Itertools;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
ops::Deref,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const TOP_CHAR: &str = "▀";
|
||||
const BOTTOM_CHAR: &str = "▄";
|
||||
|
||||
struct Inner {
|
||||
image: DynamicImage,
|
||||
cached_sizes: Mutex<HashMap<(u16, u16), RgbaImage>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AsciiImage {
|
||||
image: Arc<DynamicImage>,
|
||||
cached_sizes: Arc<Mutex<HashMap<(u16, u16), RgbaImage>>>,
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl AsciiImage {
|
||||
pub(crate) fn cache_scaling(&self, columns: u16, rows: u16) {
|
||||
let mut cached_sizes = self.cached_sizes.lock().unwrap();
|
||||
let mut cached_sizes = self.inner.cached_sizes.lock().unwrap();
|
||||
// lookup on cache/resize the image and store it in cache
|
||||
let cache_key = (columns, rows);
|
||||
if cached_sizes.get(&cache_key).is_none() {
|
||||
let image = self.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle);
|
||||
let image = self.inner.image.resize_exact(columns as u32, rows as u32, FilterType::Triangle);
|
||||
cached_sizes.insert(cache_key, image.into_rgba8());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn image(&self) -> &DynamicImage {
|
||||
&self.inner.image
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageProperties for AsciiImage {
|
||||
fn dimensions(&self) -> (u32, u32) {
|
||||
self.image.dimensions()
|
||||
self.inner.image.dimensions()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DynamicImage> for AsciiImage {
|
||||
fn from(image: DynamicImage) -> Self {
|
||||
let image = image.into_rgba8();
|
||||
Self { image: Arc::new(image.into()), cached_sizes: Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for AsciiImage {
|
||||
type Target = DynamicImage;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.image
|
||||
let inner = Inner { image: image.into(), cached_sizes: Default::default() };
|
||||
Self { inner: Arc::new(inner) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +108,7 @@ impl PrintImage for AsciiPrinter {
|
||||
|
||||
// lookup on cache/resize the image and store it in cache
|
||||
let cache_key = (columns, rows);
|
||||
let cached_sizes = image.cached_sizes.lock().unwrap();
|
||||
let cached_sizes = image.inner.cached_sizes.lock().unwrap();
|
||||
let image = cached_sizes.get(&cache_key).expect("scaled image no longer there");
|
||||
|
||||
let default_background = options.background_color.map(Color::from);
|
||||
|
@ -146,7 +146,7 @@ impl<I: TerminalWrite> Terminal<I> {
|
||||
|
||||
fn print_image(&mut self, image: &Image, options: &PrintOptions) -> Result<(), PrintImageError> {
|
||||
let image_printer = self.image_printer.clone();
|
||||
image_printer.print(&image.image, options, self)?;
|
||||
image_printer.print(image.image(), options, self)?;
|
||||
self.cursor_row += options.rows;
|
||||
Ok(())
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ use super::{
|
||||
printer::{TerminalError, TerminalIo},
|
||||
};
|
||||
use crate::{
|
||||
ImageRegistry, WindowSize,
|
||||
WindowSize,
|
||||
markdown::{
|
||||
elements::Text,
|
||||
text_style::{Color, Colors, TextStyle},
|
||||
@ -187,8 +187,8 @@ impl VirtualTerminal {
|
||||
let image = PrintedImage { image: image.clone(), width_columns: options.columns };
|
||||
self.images.insert(key, image);
|
||||
}
|
||||
ImageBehavior::PrintAscii(registry) => {
|
||||
let image = registry.as_ascii(image);
|
||||
ImageBehavior::PrintAscii => {
|
||||
let image = image.to_ascii();
|
||||
let image_printer = AsciiPrinter;
|
||||
image_printer.print(&image, options, self)?
|
||||
}
|
||||
@ -228,7 +228,7 @@ impl TerminalIo for VirtualTerminal {
|
||||
pub(crate) enum ImageBehavior {
|
||||
#[default]
|
||||
Store,
|
||||
PrintAscii(ImageRegistry),
|
||||
PrintAscii,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
Loading…
x
Reference in New Issue
Block a user