Repair animation integration paths
This commit is contained in:
parent
31c289f628
commit
0fefe814c3
9 changed files with 229 additions and 35 deletions
|
|
@ -195,13 +195,14 @@ impl RetainedToplevel {
|
||||||
impl RetainedSurface {
|
impl RetainedSurface {
|
||||||
fn capture(surface: &WlSurface, offset: (i32, i32)) -> Option<Self> {
|
fn capture(surface: &WlSurface, offset: (i32, i32)) -> Option<Self> {
|
||||||
let buffer = surface.buffer.get()?;
|
let buffer = surface.buffer.get()?;
|
||||||
|
buffer.buffer.buf.update_texture_or_log(surface, true);
|
||||||
let size = surface.buffer_abs_pos.get().size();
|
let size = surface.buffer_abs_pos.get().size();
|
||||||
let source = *surface.buffer_points_norm.borrow();
|
let source = *surface.buffer_points_norm.borrow();
|
||||||
let color_description = surface.color_description();
|
let color_description = surface.color_description();
|
||||||
let render_intent = surface.render_intent();
|
let render_intent = surface.render_intent();
|
||||||
let alpha_mode = surface.alpha_mode();
|
let alpha_mode = surface.alpha_mode();
|
||||||
let alpha = surface.alpha();
|
let alpha = surface.alpha();
|
||||||
let content = match buffer.buffer.buf.get_stable_texture() {
|
let content = match buffer.buffer.buf.get_texture(surface) {
|
||||||
Some(texture) => RetainedContent::Texture {
|
Some(texture) => RetainedContent::Texture {
|
||||||
opaque: surface.opaque(),
|
opaque: surface.opaque(),
|
||||||
texture,
|
texture,
|
||||||
|
|
@ -237,14 +238,18 @@ impl RetainedSurface {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let pos = child.sub_surface.position.get();
|
let pos = child.sub_surface.position.get();
|
||||||
below.push(Self::capture(&child.sub_surface.surface, pos)?);
|
if let Some(surface) = Self::capture(&child.sub_surface.surface, pos) {
|
||||||
|
below.push(surface);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for child in children.above.iter() {
|
for child in children.above.iter() {
|
||||||
if child.pending.get() {
|
if child.pending.get() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let pos = child.sub_surface.position.get();
|
let pos = child.sub_surface.position.get();
|
||||||
above.push(Self::capture(&child.sub_surface.surface, pos)?);
|
if let Some(surface) = Self::capture(&child.sub_surface.surface, pos) {
|
||||||
|
above.push(surface);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Self {
|
Some(Self {
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ pub enum PlanStrategy {
|
||||||
SingleAction,
|
SingleAction,
|
||||||
MixedSinglePhase,
|
MixedSinglePhase,
|
||||||
HierarchyOrderedScales,
|
HierarchyOrderedScales,
|
||||||
|
OrientationChange { from_axis: PhaseAxis },
|
||||||
SwapLanes { axis: PhaseAxis },
|
SwapLanes { axis: PhaseAxis },
|
||||||
SpaceThenOrthogonalGrowth { axis: PhaseAxis },
|
SpaceThenOrthogonalGrowth { axis: PhaseAxis },
|
||||||
ReversedForwardPlan { original: Box<PlanStrategy> },
|
ReversedForwardPlan { original: Box<PlanStrategy> },
|
||||||
|
|
@ -501,6 +502,22 @@ fn plan_forward(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
|
||||||
|
match plan_orientation_change(request, axis) {
|
||||||
|
Ok(plan) => return Ok(plan),
|
||||||
|
Err(error) => {
|
||||||
|
record_rejection(
|
||||||
|
&mut attempted,
|
||||||
|
direction,
|
||||||
|
PlanStrategy::OrientationChange { from_axis: axis },
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if error != MultiphasePlanFailure::NoPattern {
|
||||||
|
rejection.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
|
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
|
||||||
match plan_axis_crossing_lanes(request, axis) {
|
match plan_axis_crossing_lanes(request, axis) {
|
||||||
Ok(plan) => return Ok(plan),
|
Ok(plan) => return Ok(plan),
|
||||||
|
|
@ -920,6 +937,94 @@ fn plan_space_then_orthogonal_growth(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn plan_orientation_change(
|
||||||
|
request: &MultiphaseRequest,
|
||||||
|
from_axis: PhaseAxis,
|
||||||
|
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
|
||||||
|
if request.windows.len() < 2 {
|
||||||
|
return Err(MultiphasePlanFailure::NoPattern);
|
||||||
|
}
|
||||||
|
let to_axis = from_axis.other();
|
||||||
|
let min_lane_size = sane_min_size(main_size(request.bounds, to_axis));
|
||||||
|
let target_start = request
|
||||||
|
.windows
|
||||||
|
.first()
|
||||||
|
.map(|window| main_start(window.to, from_axis))
|
||||||
|
.ok_or(MultiphasePlanFailure::NoPattern)?;
|
||||||
|
let target_end = request
|
||||||
|
.windows
|
||||||
|
.first()
|
||||||
|
.map(|window| main_end(window.to, from_axis))
|
||||||
|
.ok_or(MultiphasePlanFailure::NoPattern)?;
|
||||||
|
let source_start = request
|
||||||
|
.windows
|
||||||
|
.first()
|
||||||
|
.map(|window| main_start(window.from, to_axis))
|
||||||
|
.ok_or(MultiphasePlanFailure::NoPattern)?;
|
||||||
|
let source_end = request
|
||||||
|
.windows
|
||||||
|
.first()
|
||||||
|
.map(|window| main_end(window.from, to_axis))
|
||||||
|
.ok_or(MultiphasePlanFailure::NoPattern)?;
|
||||||
|
if request.windows.iter().any(|window| {
|
||||||
|
main_start(window.from, to_axis) != source_start
|
||||||
|
|| main_end(window.from, to_axis) != source_end
|
||||||
|
|| main_start(window.to, from_axis) != target_start
|
||||||
|
|| main_end(window.to, from_axis) != target_end
|
||||||
|
|| main_size(window.to, to_axis) < min_lane_size
|
||||||
|
}) {
|
||||||
|
return Err(MultiphasePlanFailure::NoPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut phase1 = vec![];
|
||||||
|
let mut phase2 = vec![];
|
||||||
|
let mut phase3 = vec![];
|
||||||
|
for window in &request.windows {
|
||||||
|
let lane = with_main_interval(
|
||||||
|
window.from,
|
||||||
|
to_axis,
|
||||||
|
main_start(window.to, to_axis),
|
||||||
|
main_end(window.to, to_axis),
|
||||||
|
);
|
||||||
|
let moved = with_main_interval(
|
||||||
|
lane,
|
||||||
|
from_axis,
|
||||||
|
main_start(window.to, from_axis),
|
||||||
|
main_start(window.to, from_axis) + main_size(lane, from_axis),
|
||||||
|
);
|
||||||
|
push_step(&mut phase1, window.node_id, window.from, lane);
|
||||||
|
push_step(&mut phase2, window.node_id, lane, moved);
|
||||||
|
push_step(&mut phase3, window.node_id, moved, window.to);
|
||||||
|
}
|
||||||
|
if phase1.is_empty() || phase3.is_empty() {
|
||||||
|
return Err(MultiphasePlanFailure::NoPattern);
|
||||||
|
}
|
||||||
|
build_validated_plan(
|
||||||
|
request,
|
||||||
|
PlanStrategy::OrientationChange { from_axis },
|
||||||
|
[
|
||||||
|
phase_draft(
|
||||||
|
PhaseKind::Scale,
|
||||||
|
to_axis,
|
||||||
|
phase1,
|
||||||
|
PhaseReason::ShrinkIntoLanes { lane_axis: to_axis },
|
||||||
|
),
|
||||||
|
phase_draft(
|
||||||
|
PhaseKind::Move,
|
||||||
|
from_axis,
|
||||||
|
phase2,
|
||||||
|
PhaseReason::MoveThroughFreedSpace,
|
||||||
|
),
|
||||||
|
phase_draft(
|
||||||
|
PhaseKind::Scale,
|
||||||
|
from_axis,
|
||||||
|
phase3,
|
||||||
|
PhaseReason::GrowOutOfLanes,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
struct MultiphasePhaseDraft {
|
struct MultiphasePhaseDraft {
|
||||||
action: MultiphasePhaseActionDraft,
|
action: MultiphasePhaseActionDraft,
|
||||||
steps: Vec<MultiphaseStep>,
|
steps: Vec<MultiphaseStep>,
|
||||||
|
|
@ -1665,6 +1770,20 @@ mod tests {
|
||||||
strategy: PlanStrategy::HierarchyOrderedScales,
|
strategy: PlanStrategy::HierarchyOrderedScales,
|
||||||
reason: MultiphasePlanFailure::NoPattern,
|
reason: MultiphasePlanFailure::NoPattern,
|
||||||
},
|
},
|
||||||
|
RejectedStrategy {
|
||||||
|
direction,
|
||||||
|
strategy: PlanStrategy::OrientationChange {
|
||||||
|
from_axis: PhaseAxis::Horizontal,
|
||||||
|
},
|
||||||
|
reason: MultiphasePlanFailure::NoPattern,
|
||||||
|
},
|
||||||
|
RejectedStrategy {
|
||||||
|
direction,
|
||||||
|
strategy: PlanStrategy::OrientationChange {
|
||||||
|
from_axis: PhaseAxis::Vertical,
|
||||||
|
},
|
||||||
|
reason: MultiphasePlanFailure::NoPattern,
|
||||||
|
},
|
||||||
RejectedStrategy {
|
RejectedStrategy {
|
||||||
direction,
|
direction,
|
||||||
strategy: PlanStrategy::SwapLanes {
|
strategy: PlanStrategy::SwapLanes {
|
||||||
|
|
@ -2024,6 +2143,46 @@ mod tests {
|
||||||
assert!(validate_plan_continuous(&req, plan));
|
assert!(validate_plan_continuous(&req, plan));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orientation_change_shrinks_moves_then_grows() {
|
||||||
|
let req = request(vec![
|
||||||
|
window(1, rect(0, 0, 200, 400), rect(0, 0, 400, 200)),
|
||||||
|
window(2, rect(200, 0, 400, 400), rect(0, 200, 400, 400)),
|
||||||
|
]);
|
||||||
|
let planned = plan_no_overlap_explained(&req).unwrap();
|
||||||
|
let plan = &planned.plan;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
planned.explanation.strategy,
|
||||||
|
PlanStrategy::OrientationChange {
|
||||||
|
from_axis: PhaseAxis::Horizontal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
actions(plan),
|
||||||
|
vec![
|
||||||
|
PhaseAction {
|
||||||
|
kind: PhaseKind::Scale,
|
||||||
|
axis: PhaseAxis::Vertical,
|
||||||
|
},
|
||||||
|
PhaseAction {
|
||||||
|
kind: PhaseKind::Move,
|
||||||
|
axis: PhaseAxis::Horizontal,
|
||||||
|
},
|
||||||
|
PhaseAction {
|
||||||
|
kind: PhaseKind::Scale,
|
||||||
|
axis: PhaseAxis::Horizontal,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 200, 200));
|
||||||
|
assert_eq!(step_to(plan, 0, id(2)), rect(200, 200, 400, 400));
|
||||||
|
assert_eq!(step_to(plan, 1, id(2)), rect(0, 200, 200, 400));
|
||||||
|
assert_eq!(step_to(plan, 2, id(1)), rect(0, 0, 400, 200));
|
||||||
|
assert_eq!(step_to(plan, 2, id(2)), rect(0, 200, 400, 400));
|
||||||
|
assert!(validate_plan_continuous(&req, plan));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn two_axis_redistribution_without_hierarchy_still_falls_back() {
|
fn two_axis_redistribution_without_hierarchy_still_falls_back() {
|
||||||
let req = request(vec![
|
let req = request(vec![
|
||||||
|
|
|
||||||
|
|
@ -310,19 +310,6 @@ impl WlBuffer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_stable_texture(&self) -> Option<Rc<dyn GfxTexture>> {
|
|
||||||
match &*self.storage.borrow() {
|
|
||||||
None => None,
|
|
||||||
Some(s) => match s {
|
|
||||||
WlBufferStorage::Shm {
|
|
||||||
dmabuf_buffer_params,
|
|
||||||
..
|
|
||||||
} => dmabuf_buffer_params.tex.clone(),
|
|
||||||
WlBufferStorage::Dmabuf { tex, .. } => tex.clone(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_texture_or_log(&self, surface: &WlSurface, sync_shm: bool) {
|
pub fn update_texture_or_log(&self, surface: &WlSurface, sync_shm: bool) {
|
||||||
if let Err(e) = self.update_texture(surface, sync_shm) {
|
if let Err(e) = self.update_texture(surface, sync_shm) {
|
||||||
log::warn!("Could not update texture: {}", ErrorFmt(e));
|
log::warn!("Could not update texture: {}", ErrorFmt(e));
|
||||||
|
|
|
||||||
|
|
@ -628,6 +628,11 @@ fn schedule_async_upload(
|
||||||
{
|
{
|
||||||
back_tex_opt = None;
|
back_tex_opt = None;
|
||||||
}
|
}
|
||||||
|
if let Some(back_tex) = &back_tex_opt
|
||||||
|
&& Rc::strong_count(back_tex) > 1
|
||||||
|
{
|
||||||
|
back_tex_opt = None;
|
||||||
|
}
|
||||||
let damage_full = || {
|
let damage_full = || {
|
||||||
back.damage.clear();
|
back.damage.clear();
|
||||||
back.damage.damage(slice::from_ref(&buf.rect));
|
back.damage.damage(slice::from_ref(&buf.rect));
|
||||||
|
|
|
||||||
|
|
@ -836,7 +836,14 @@ impl State {
|
||||||
|
|
||||||
pub fn map_tiled(self: &Rc<Self>, node: Rc<dyn ToplevelNode>) {
|
pub fn map_tiled(self: &Rc<Self>, node: Rc<dyn ToplevelNode>) {
|
||||||
let seat = self.seat_queue.last();
|
let seat = self.seat_queue.last();
|
||||||
self.do_map_tiled(seat.as_deref(), node.clone());
|
let animate_new_app_map = node.tl_data().parent.is_none()
|
||||||
|
&& node.tl_data().kind.is_app_window()
|
||||||
|
&& !node.tl_data().visible.get();
|
||||||
|
if animate_new_app_map {
|
||||||
|
self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone()));
|
||||||
|
} else {
|
||||||
|
self.do_map_tiled(seat.as_deref(), node.clone());
|
||||||
|
}
|
||||||
self.focus_after_map(node, seat.as_deref());
|
self.focus_after_map(node, seat.as_deref());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1766,17 +1766,38 @@ enum SeatOpKind {
|
||||||
|
|
||||||
pub async fn container_layout(state: Rc<State>) {
|
pub async fn container_layout(state: Rc<State>) {
|
||||||
loop {
|
loop {
|
||||||
let container = state.pending_container_layout.pop().await;
|
let first = state.pending_container_layout.pop().await;
|
||||||
if container.layout_scheduled.get() {
|
let mut containers = vec![first];
|
||||||
|
while let Some(container) = state.pending_container_layout.try_pop() {
|
||||||
|
containers.push(container);
|
||||||
|
}
|
||||||
|
let mut animated = vec![];
|
||||||
|
let mut immediate = vec![];
|
||||||
|
for container in containers {
|
||||||
|
if !container.layout_scheduled.get() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let animate = container.animate_next_layout.replace(false)
|
let animate = container.animate_next_layout.replace(false)
|
||||||
&& !state.suppress_animations_for_next_layout.get();
|
&& !state.suppress_animations_for_next_layout.get();
|
||||||
let prev_active = state.layout_animations_active.replace(animate);
|
|
||||||
if animate {
|
if animate {
|
||||||
state.begin_layout_animation_batch();
|
animated.push(container);
|
||||||
|
} else {
|
||||||
|
immediate.push(container);
|
||||||
}
|
}
|
||||||
container.perform_layout();
|
}
|
||||||
if animate {
|
if !animated.is_empty() {
|
||||||
state.finish_layout_animation_batch();
|
let prev_active = state.layout_animations_active.replace(true);
|
||||||
|
state.begin_layout_animation_batch();
|
||||||
|
for container in animated {
|
||||||
|
container.perform_layout();
|
||||||
|
}
|
||||||
|
state.finish_layout_animation_batch();
|
||||||
|
state.layout_animations_active.set(prev_active);
|
||||||
|
}
|
||||||
|
if !immediate.is_empty() {
|
||||||
|
let prev_active = state.layout_animations_active.replace(false);
|
||||||
|
for container in immediate {
|
||||||
|
container.perform_layout();
|
||||||
}
|
}
|
||||||
state.layout_animations_active.set(prev_active);
|
state.layout_animations_active.set(prev_active);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ use {
|
||||||
crate::{
|
crate::{
|
||||||
animation::{
|
animation::{
|
||||||
RetainedExitLayer, RetainedToplevel,
|
RetainedExitLayer, RetainedToplevel,
|
||||||
multiphase::{MultiphaseHierarchyPosition, MultiphaseWindowHierarchy, PhaseAxis},
|
multiphase::{
|
||||||
|
MultiphaseHierarchyPosition, MultiphaseHierarchyTransition,
|
||||||
|
MultiphaseWindowHierarchy, PhaseAxis,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
client::{Client, ClientId},
|
client::{Client, ClientId},
|
||||||
criteria::{
|
criteria::{
|
||||||
|
|
@ -195,18 +198,30 @@ impl<T: ToplevelNodeBase> ToplevelNode for T {
|
||||||
target_hierarchy,
|
target_hierarchy,
|
||||||
);
|
);
|
||||||
let spawn_in_pending = data.spawn_in_pending.get();
|
let spawn_in_pending = data.spawn_in_pending.get();
|
||||||
|
let spawn_in_eligible = spawn_in_pending
|
||||||
|
&& !rect.is_empty()
|
||||||
|
&& data.visible.get()
|
||||||
|
&& !data.is_fullscreen.get()
|
||||||
|
&& data.kind.is_app_window()
|
||||||
|
&& !self.node_is_container();
|
||||||
let parent_is_mono = data
|
let parent_is_mono = data
|
||||||
.parent
|
.parent
|
||||||
.get()
|
.get()
|
||||||
.and_then(|parent| parent.node_into_container())
|
.and_then(|parent| parent.node_into_container())
|
||||||
.is_some_and(|container| container.mono_child.is_some());
|
.is_some_and(|container| container.mono_child.is_some());
|
||||||
|
let active_mono_boundary = matches!(
|
||||||
|
hierarchy.transition,
|
||||||
|
MultiphaseHierarchyTransition::EnteringMono
|
||||||
|
| MultiphaseHierarchyTransition::ExitingMono
|
||||||
|
) && (hierarchy.source.mono_active
|
||||||
|
|| hierarchy.target.mono_active);
|
||||||
if prev != *rect
|
if prev != *rect
|
||||||
&& !prev.is_empty()
|
&& !prev.is_empty()
|
||||||
&& !rect.is_empty()
|
&& !rect.is_empty()
|
||||||
&& data.visible.get()
|
&& data.visible.get()
|
||||||
&& !data.parent_is_float.get()
|
&& !data.parent_is_float.get()
|
||||||
&& !self.node_is_container()
|
&& !self.node_is_container()
|
||||||
&& !parent_is_mono
|
&& (!parent_is_mono || active_mono_boundary)
|
||||||
{
|
{
|
||||||
data.state.clone().queue_tiled_animation_with_hierarchy(
|
data.state.clone().queue_tiled_animation_with_hierarchy(
|
||||||
data.node_id,
|
data.node_id,
|
||||||
|
|
@ -216,20 +231,14 @@ impl<T: ToplevelNodeBase> ToplevelNode for T {
|
||||||
hierarchy,
|
hierarchy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if spawn_in_pending
|
if spawn_in_eligible {
|
||||||
&& !rect.is_empty()
|
|
||||||
&& data.visible.get()
|
|
||||||
&& !data.is_fullscreen.get()
|
|
||||||
&& data.kind.is_app_window()
|
|
||||||
&& !self.node_is_container()
|
|
||||||
{
|
|
||||||
data.state.clone().queue_spawn_in_animation(
|
data.state.clone().queue_spawn_in_animation(
|
||||||
data.node_id,
|
data.node_id,
|
||||||
*rect,
|
*rect,
|
||||||
self.tl_animation_snapshot(),
|
self.tl_animation_snapshot(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if spawn_in_pending && !rect.is_empty() {
|
if spawn_in_eligible {
|
||||||
data.spawn_in_pending.set(false);
|
data.spawn_in_pending.set(false);
|
||||||
}
|
}
|
||||||
if prev.size() != rect.size() {
|
if prev.size() != rect.size() {
|
||||||
|
|
|
||||||
|
|
@ -197,10 +197,10 @@ impl WorkspaceNode {
|
||||||
}
|
}
|
||||||
self.pull_child_properties(&**container);
|
self.pull_child_properties(&**container);
|
||||||
let pos = self.position.get();
|
let pos = self.position.get();
|
||||||
container.clone().tl_change_extents(&pos);
|
|
||||||
container.tl_set_parent(self.clone());
|
container.tl_set_parent(self.clone());
|
||||||
container.tl_set_visible(self.container_visible());
|
container.tl_set_visible(self.container_visible());
|
||||||
self.container.set(Some(container.clone()));
|
self.container.set(Some(container.clone()));
|
||||||
|
container.clone().tl_change_extents(&pos);
|
||||||
self.state.damage(self.position.get());
|
self.state.damage(self.position.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2034,6 +2034,7 @@ impl Wm {
|
||||||
self.windows_by_surface_serial.remove(&serial);
|
self.windows_by_surface_serial.remove(&serial);
|
||||||
}
|
}
|
||||||
if let Some(window) = data.window.take() {
|
if let Some(window) = data.window.take() {
|
||||||
|
window.queue_spawn_out();
|
||||||
window.destroy();
|
window.destroy();
|
||||||
}
|
}
|
||||||
if let Some(parent) = data.parent.take() {
|
if let Some(parent) = data.parent.take() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue