mirror of
https://github.com/danbulant/despot
synced 2026-07-05 02:50:39 +00:00
working virtual list
This commit is contained in:
parent
e51e057d85
commit
d5ec39ea53
4 changed files with 351 additions and 54 deletions
28
src/main.rs
28
src/main.rs
|
|
@ -4,10 +4,10 @@ use api::SpotifyContext;
|
||||||
use auth::get_token;
|
use auth::get_token;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::Args;
|
use cli::Args;
|
||||||
use cushy::{figures::units::Lp, value::Dynamic, widget::MakeWidget, window::MakeWindow, Application, Open, PendingApp, Run, TokioRuntime};
|
use cushy::{figures::units::Lp, styles::Dimension, value::Dynamic, widget::MakeWidget, window::MakeWindow, Application, Open, PendingApp, Run, TokioRuntime};
|
||||||
use librespot_core::{authentication::Credentials, Session, SessionConfig};
|
use librespot_core::{authentication::Credentials, Session, SessionConfig};
|
||||||
use librespot_playback::{audio_backend, config::{AudioFormat, PlayerConfig}, mixer::NoOpVolume, player::Player};
|
use librespot_playback::{audio_backend, config::{AudioFormat, PlayerConfig}, mixer::NoOpVolume, player::Player};
|
||||||
use widgets::{library::playlist::playlists_widget, virtual_list::{virtual_list, VirtualList}, ActivePage};
|
use widgets::{library::playlist::playlists_widget, virtual_list::{VirtualListContent, VirtualList}, ActivePage};
|
||||||
|
|
||||||
mod vibrancy;
|
mod vibrancy;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
|
@ -17,19 +17,23 @@ mod widgets;
|
||||||
mod rt;
|
mod rt;
|
||||||
mod api;
|
mod api;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct TestVirtualList;
|
struct TestVirtualList;
|
||||||
|
|
||||||
impl VirtualList for TestVirtualList {
|
impl VirtualListContent for TestVirtualList {
|
||||||
fn item_count(&self) -> impl cushy::value::IntoDynamic<usize> {
|
fn item_count(&self) -> impl cushy::value::IntoValue<usize> {
|
||||||
Dynamic::new(100)
|
50
|
||||||
}
|
}
|
||||||
fn item_height(&self) -> impl cushy::value::IntoDynamic<cushy::styles::Dimension> {
|
fn item_height(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
|
||||||
Dynamic::new(cushy::styles::Dimension::Lp(Lp::inches_f(0.5)))
|
cushy::styles::Dimension::Lp(Lp::inches_f(0.5))
|
||||||
}
|
}
|
||||||
fn widget_at(&self, index: usize) -> impl MakeWidget {
|
fn widget_at(&self, index: usize) -> impl MakeWidget {
|
||||||
// println!("Creating item {}", index);
|
// println!("Creating item {}", index);
|
||||||
format!("Item {}", index)
|
format!("Item {}", index)
|
||||||
}
|
}
|
||||||
|
fn width(&self) -> impl cushy::value::IntoValue<cushy::styles::Dimension> {
|
||||||
|
Dimension::Lp(Lp::inches_f(10.))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> cushy::Result {
|
fn main() -> cushy::Result {
|
||||||
|
|
@ -84,11 +88,11 @@ fn main() -> cushy::Result {
|
||||||
|
|
||||||
let selected_page = Dynamic::new(ActivePage::default());
|
let selected_page = Dynamic::new(ActivePage::default());
|
||||||
|
|
||||||
playlists_widget(playlists.items, selected_page)
|
// playlists_widget(playlists.items, selected_page)
|
||||||
.and(
|
// .and(
|
||||||
virtual_list(TestVirtualList)
|
VirtualList::new(TestVirtualList)
|
||||||
)
|
// )
|
||||||
.into_columns()
|
// .into_columns()
|
||||||
.make_window()
|
.make_window()
|
||||||
.open(&mut app)
|
.open(&mut app)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ pub mod image;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod virtual_list;
|
pub mod virtual_list;
|
||||||
pub mod probe;
|
pub mod probe;
|
||||||
|
pub mod owned;
|
||||||
|
|
||||||
#[derive(PartialEq, Debug, Default)]
|
#[derive(PartialEq, Debug, Default)]
|
||||||
pub enum ActivePage {
|
pub enum ActivePage {
|
||||||
|
|
|
||||||
56
src/widgets/owned.rs
Normal file
56
src/widgets/owned.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
use cushy::widget::{Widget, WidgetRef};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OwnedWidget<W>(OwnedWidgetState<W>);
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum OwnedWidgetState<W> {
|
||||||
|
Unmade(W),
|
||||||
|
Making,
|
||||||
|
Made(WidgetRef),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W> OwnedWidget<W>
|
||||||
|
where
|
||||||
|
W: Widget,
|
||||||
|
{
|
||||||
|
pub const fn new(widget: W) -> Self {
|
||||||
|
Self(OwnedWidgetState::Unmade(widget))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_if_needed(&mut self) -> &mut WidgetRef {
|
||||||
|
if matches!(&self.0, OwnedWidgetState::Unmade(_)) {
|
||||||
|
let OwnedWidgetState::Unmade(widget) =
|
||||||
|
std::mem::replace(&mut self.0, OwnedWidgetState::Making)
|
||||||
|
else {
|
||||||
|
unreachable!("just matched")
|
||||||
|
};
|
||||||
|
|
||||||
|
self.0 = OwnedWidgetState::Made(WidgetRef::new(widget));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.expect_made_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expect_made(&self) -> &WidgetRef {
|
||||||
|
let OwnedWidgetState::Made(widget) = &self.0 else {
|
||||||
|
unreachable!("widget made")
|
||||||
|
};
|
||||||
|
widget
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expect_made_mut(&mut self) -> &mut WidgetRef {
|
||||||
|
let OwnedWidgetState::Made(widget) = &mut self.0 else {
|
||||||
|
unreachable!("widget made")
|
||||||
|
};
|
||||||
|
widget
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expect_unmade_mut(&mut self) -> &mut W {
|
||||||
|
let OwnedWidgetState::Unmade(widget) = &mut self.0 else {
|
||||||
|
unreachable!("widget unmade")
|
||||||
|
};
|
||||||
|
widget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,58 +1,294 @@
|
||||||
use cushy::{figures::{Round, ScreenScale, Size, Zero}, styles::{Dimension, DimensionRange, Edges}, value::{Destination, Dynamic, ForEach, IntoDynamic, MapEach, Source}, widget::{MakeWidget, WidgetList}, widgets::{Container, Scroll, Stack}};
|
use std::{collections::VecDeque, fmt::Debug, ops::Range};
|
||||||
use crate::widgets::probe::ScalingProbe;
|
|
||||||
|
|
||||||
pub trait VirtualList {
|
use cushy::{context::{AsEventContext, EventContext}, figures::{units::{Px, UPx}, IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero}, kludgine::app::winit::{event::{ MouseScrollDelta, TouchPhase}, window::CursorIcon}, styles::Dimension, value::{Destination, Dynamic, DynamicReader, IntoDynamic, IntoValue, Source}, widget::{EventHandling, MakeWidget, MountedWidget, Widget, HANDLED, IGNORED}, widgets::scroll::ScrollBar, window::DeviceId, ConstraintLimit};
|
||||||
fn item_height(&self) -> impl IntoDynamic<Dimension>;
|
|
||||||
// fn width(&self) -> impl IntoDynamic<DimensionRange>;
|
use super::owned::OwnedWidget;
|
||||||
fn item_count(&self) -> impl IntoDynamic<usize>;
|
|
||||||
|
/// A virtual list contents.
|
||||||
|
/// This simple virtual list assumes that all items have the same height, width and that the item count is known.
|
||||||
|
/// All the values are dynamic, so the list will update when the values change.
|
||||||
|
pub trait VirtualListContent: Debug {
|
||||||
|
fn item_height(&self) -> impl IntoValue<Dimension>;
|
||||||
|
fn width(&self) -> impl IntoValue<Dimension>;
|
||||||
|
fn item_count(&self) -> impl IntoValue<usize>;
|
||||||
fn widget_at(&self, index: usize) -> impl MakeWidget;
|
fn widget_at(&self, index: usize) -> impl MakeWidget;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn virtual_list<T>(list: T) -> impl MakeWidget
|
#[derive(Debug)]
|
||||||
where
|
struct VirtualListItem {
|
||||||
T: VirtualList + Send + 'static
|
index: usize,
|
||||||
{
|
mounted: MountedWidget,
|
||||||
let contents = Dynamic::default();
|
}
|
||||||
let stack = Stack::rows(contents.clone());
|
|
||||||
let padding = Dynamic::default();
|
|
||||||
let container = Container::new(stack).transparent().pad_by(padding.clone());
|
|
||||||
let scroll = Scroll::vertical(container);
|
|
||||||
|
|
||||||
// Current scroll position
|
#[derive(Debug)]
|
||||||
let current_scroll = scroll.scroll.clone().map_each(|scroll| scroll.y);
|
pub struct VirtualList<T: VirtualListContent + Send + 'static> {
|
||||||
// height of the scroll widget
|
virtual_list: T,
|
||||||
let visible_size = scroll.control_size().map_each(|size| size.height);
|
vertical_scroll: OwnedWidget<ScrollBar>,
|
||||||
// max scroll position. Height of contents is max_scroll + visible_size
|
items: VecDeque<VirtualListItem>,
|
||||||
// let max_scroll = scroll.max_scroll().map_each(|size| size.y);
|
content_size: Dynamic<Size<UPx>>,
|
||||||
|
pub max_scroll: DynamicReader<Point<UPx>>,
|
||||||
|
pub scroll: Dynamic<Point<UPx>>,
|
||||||
|
control_size: Dynamic<Size<UPx>>,
|
||||||
|
|
||||||
let item_height = list.item_height().into_dynamic();
|
pub item_height: DynamicReader<Dimension>,
|
||||||
let item_count = list.item_count().into_dynamic();
|
pub width: DynamicReader<Dimension>,
|
||||||
|
pub item_count: DynamicReader<usize>,
|
||||||
|
|
||||||
let probe = ScalingProbe::new(scroll);
|
visible_range: Dynamic<Range<usize>>
|
||||||
let scale = probe.scale();
|
}
|
||||||
|
|
||||||
// let width = list.width().into_dynamic();
|
impl<T: VirtualListContent + Send + 'static> VirtualList<T> {
|
||||||
|
pub fn new(virtual_list: T) -> Self {
|
||||||
|
let scroll = Dynamic::<Point<UPx>>::default();
|
||||||
|
let item_height = virtual_list.item_height().into_value().into_dynamic().create_reader();
|
||||||
|
let width = virtual_list.width().into_value().into_dynamic().create_reader();
|
||||||
|
let item_count = virtual_list.item_count().into_value().into_dynamic().create_reader();
|
||||||
|
let content_size = Dynamic::new(Size::default());
|
||||||
|
|
||||||
// (&width, &item_height, &item_count, &scale).map_each(|(width, item_height, item_count, scale)| {
|
let y = scroll.map_each_cloned(|scroll| scroll.y);
|
||||||
// Size::new(*width, *item_height.into_upx(*scale) * *item_count as f32)
|
y.for_each_cloned({
|
||||||
// });
|
let scroll = scroll.clone();
|
||||||
|
move |y| {
|
||||||
|
if let Ok(mut scroll) = scroll.try_lock() {
|
||||||
|
if scroll.y != y {
|
||||||
|
scroll.y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.persist();
|
||||||
|
let vertical =
|
||||||
|
ScrollBar::new(content_size.map_each_cloned(|size| size.height), y, true);
|
||||||
|
let max_scroll = (&vertical.max_scroll())
|
||||||
|
.map_each_cloned(|y| Point::new(UPx::ZERO, y))
|
||||||
|
.into_reader();
|
||||||
|
|
||||||
let handle = (¤t_scroll, &item_height, &item_count, &scale, &visible_size).for_each(move |(current_scroll, item_height, item_count, scale, visible_size)| {
|
Self {
|
||||||
let start = (*current_scroll / item_height.into_upx(*scale)).floor().get();
|
virtual_list,
|
||||||
let end = ((*current_scroll + *visible_size) / item_height.into_upx(*scale)).ceil().get().min(*item_count as _);
|
vertical_scroll: OwnedWidget::new(vertical),
|
||||||
println!("Start: {}, End: {}", start, end);
|
items: VecDeque::new(),
|
||||||
|
control_size: Dynamic::new(Size::default()),
|
||||||
|
content_size,
|
||||||
|
max_scroll,
|
||||||
|
scroll,
|
||||||
|
|
||||||
let list = (start as usize..end as usize).map(|index| list.widget_at(index));
|
item_height,
|
||||||
|
width,
|
||||||
|
item_count,
|
||||||
|
visible_range: Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let padding_start = *item_height * start as i32;
|
/// Returns a reader for the maximum scroll value.
|
||||||
let items_end = (*item_count as u32).saturating_sub(end);
|
///
|
||||||
let padding_end = *item_height * items_end as i32;
|
/// This represents the maximum amount that the scroll can be moved by.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn max_scroll(&self) -> &DynamicReader<Point<UPx>> {
|
||||||
|
&self.max_scroll
|
||||||
|
}
|
||||||
|
|
||||||
padding.set(Edges::ZERO.with_top(padding_start).with_bottom(padding_end));
|
/// Returns a reader for the size of the scrollable area.
|
||||||
|
#[must_use]
|
||||||
|
pub fn content_size(&self) -> DynamicReader<Size<UPx>> {
|
||||||
|
self.content_size.create_reader()
|
||||||
|
}
|
||||||
|
|
||||||
contents.set(WidgetList::from_iter(list));
|
/// Returns a reader for the size of this Scroll widget.
|
||||||
});
|
#[must_use]
|
||||||
handle.persist();
|
pub fn control_size(&self) -> DynamicReader<Size<UPx>> {
|
||||||
|
self.control_size.create_reader()
|
||||||
|
}
|
||||||
|
|
||||||
probe
|
#[must_use]
|
||||||
|
pub fn visible_range(&self) -> DynamicReader<Range<usize>> {
|
||||||
|
self.visible_range.create_reader()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_scrollbars(&mut self, context: &mut EventContext<'_>) {
|
||||||
|
let mut vertical = self.vertical_scroll.expect_made_mut().widget().lock();
|
||||||
|
vertical
|
||||||
|
.downcast_mut::<ScrollBar>()
|
||||||
|
.expect("a ScrollBar")
|
||||||
|
.show(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_scrollbars(&mut self, context: &mut EventContext<'_>) {
|
||||||
|
let mut vertical = self.vertical_scroll.expect_made_mut().widget().lock();
|
||||||
|
vertical
|
||||||
|
.downcast_mut::<ScrollBar>()
|
||||||
|
.expect("a ScrollBar")
|
||||||
|
.hide(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: VirtualListContent + Send + 'static> Widget for VirtualList<T> {
|
||||||
|
fn hit_test(&mut self, _location: Point<Px>, _context: &mut EventContext<'_>) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hover(
|
||||||
|
&mut self,
|
||||||
|
_location: Point<Px>,
|
||||||
|
context: &mut EventContext<'_>,
|
||||||
|
) -> Option<CursorIcon> {
|
||||||
|
self.show_scrollbars(context);
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unhover(&mut self, context: &mut EventContext<'_>) {
|
||||||
|
self.hide_scrollbars(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mounted(&mut self, context: &mut EventContext<'_>) {
|
||||||
|
for child in &mut self.items {
|
||||||
|
child.mounted.remount_if_needed(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redraw(&mut self, context: &mut cushy::context::GraphicsContext<'_, '_, '_, '_>) {
|
||||||
|
for child in &mut self.items {
|
||||||
|
context.for_other(&child.mounted).redraw();
|
||||||
|
}
|
||||||
|
let vertical = self
|
||||||
|
.vertical_scroll
|
||||||
|
.expect_made_mut()
|
||||||
|
.mounted(&mut context.as_event_context());
|
||||||
|
context.for_other(&vertical).redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&mut self,
|
||||||
|
available_space: Size<cushy::ConstraintLimit>,
|
||||||
|
context: &mut cushy::context::LayoutContext<'_, '_, '_, '_>,
|
||||||
|
) -> Size<UPx> {
|
||||||
|
let item_height = self.item_height.get_tracking_invalidate(context);
|
||||||
|
let item_height_upx = item_height.into_upx(context.gfx.scale());
|
||||||
|
let item_count = self.item_count.get_tracking_invalidate(context);
|
||||||
|
let content_height = item_height * item_count as i32;
|
||||||
|
let content_height = content_height.into_upx(context.gfx.scale());
|
||||||
|
let width = self.width.get_tracking_invalidate(context);
|
||||||
|
let width = width.into_upx(context.gfx.scale());
|
||||||
|
|
||||||
|
let new_control_size = Size::new(
|
||||||
|
width,
|
||||||
|
constrain_child(available_space.height, content_height),
|
||||||
|
);
|
||||||
|
|
||||||
|
let vertical = self
|
||||||
|
.vertical_scroll
|
||||||
|
.make_if_needed()
|
||||||
|
.mounted(&mut context.as_event_context());
|
||||||
|
let scrollbar_layout = context.for_other(&vertical).layout(available_space);
|
||||||
|
context.set_child_layout(
|
||||||
|
&vertical,
|
||||||
|
Rect::new(
|
||||||
|
Point::new(
|
||||||
|
available_space.width
|
||||||
|
.fit_measured(new_control_size.width)
|
||||||
|
.saturating_sub(scrollbar_layout.width)
|
||||||
|
.into_signed(),
|
||||||
|
Px::ZERO,
|
||||||
|
),
|
||||||
|
scrollbar_layout.into_signed(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let scroll = self.scroll.get_tracking_invalidate(context);
|
||||||
|
|
||||||
|
let start_item = (scroll.y / item_height_upx).floor().get() as usize;
|
||||||
|
let end_item = ((scroll.y + new_control_size.height) / item_height_upx).ceil().get() as usize;
|
||||||
|
let end_item = end_item.min(item_count-1);
|
||||||
|
|
||||||
|
self.visible_range.set(start_item..end_item);
|
||||||
|
|
||||||
|
let first = self.items.front().map(|t| t.index);
|
||||||
|
let last = self.items.back().map(|t| t.index);
|
||||||
|
let mut closure = |index| {
|
||||||
|
let widget = self.virtual_list.widget_at(index);
|
||||||
|
let mut widget = widget.widget_ref();
|
||||||
|
let mounted = widget.mounted(&mut context.as_event_context());
|
||||||
|
VirtualListItem { index, mounted }
|
||||||
|
};
|
||||||
|
if self.items.is_empty() || first.unwrap() > end_item || last.unwrap() < start_item {
|
||||||
|
self.items.clear();
|
||||||
|
self.items.extend((start_item..=end_item).map(closure));
|
||||||
|
} else {
|
||||||
|
let first = first.expect("List is not empty");
|
||||||
|
let last = last.expect("List is not empty");
|
||||||
|
if first < start_item {
|
||||||
|
while self.items.front().is_some() && self.items.front().expect("Checked is some").index < start_item {
|
||||||
|
self.items.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if last > end_item {
|
||||||
|
while self.items.back().is_some() && self.items.back().expect("Checked is some").index > end_item {
|
||||||
|
self.items.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no extend front :(
|
||||||
|
for item in (start_item..first).rev() {
|
||||||
|
self.items.push_front(closure(item));
|
||||||
|
}
|
||||||
|
self.items.extend(((last+1)..=end_item).map(closure));
|
||||||
|
}
|
||||||
|
|
||||||
|
let item_size = Size::new(width, item_height_upx);
|
||||||
|
let constraint = item_size.map(ConstraintLimit::Fill);
|
||||||
|
|
||||||
|
for item in &self.items {
|
||||||
|
context.for_other(&item.mounted).layout(constraint);
|
||||||
|
}
|
||||||
|
|
||||||
|
let item_size = item_size.into_signed();
|
||||||
|
let scroll = self.scroll.get_tracking_invalidate(context).into_signed();
|
||||||
|
|
||||||
|
for item in &self.items {
|
||||||
|
context.set_child_layout(
|
||||||
|
&item.mounted,
|
||||||
|
Rect::new(
|
||||||
|
Point::new(Px::ZERO, (item_height_upx * item.index as f32).into_signed() - scroll.y),
|
||||||
|
item_size,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.control_size.set(new_control_size);
|
||||||
|
self.content_size.set(Size::new(width, content_height));
|
||||||
|
|
||||||
|
new_control_size
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_wheel(
|
||||||
|
&mut self,
|
||||||
|
_device_id: DeviceId,
|
||||||
|
delta: MouseScrollDelta,
|
||||||
|
_phase: TouchPhase,
|
||||||
|
context: &mut EventContext<'_>,
|
||||||
|
) -> EventHandling {
|
||||||
|
let mut handled = false;
|
||||||
|
{
|
||||||
|
let mut vertical = self.vertical_scroll.expect_made().widget().lock();
|
||||||
|
handled |= vertical
|
||||||
|
.downcast_mut::<ScrollBar>()
|
||||||
|
.expect("a ScrollBar")
|
||||||
|
.mouse_wheel(delta, context)
|
||||||
|
.is_break();
|
||||||
|
}
|
||||||
|
if handled {
|
||||||
|
self.show_scrollbars(context);
|
||||||
|
context.set_needs_redraw();
|
||||||
|
|
||||||
|
HANDLED
|
||||||
|
} else {
|
||||||
|
IGNORED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constrain_child(constraint: ConstraintLimit, measured: UPx) -> UPx {
|
||||||
|
match constraint {
|
||||||
|
ConstraintLimit::Fill(size) => size.min(measured),
|
||||||
|
// change from Scroll widget: returning just measured here would break the functionality (render too many items)
|
||||||
|
ConstraintLimit::SizeToFit(size) => size.min(measured),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue