mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Layers panel: drag layer effects onto another layer to move them, Alt-drag copies (#1719)
Effect rows only took clicks and the fx badge was part of the layer row, so dragging an effect or the fx badge picked up nothing (or the whole layer). As in Photoshop, dragging the fx badge or the "Effects" row onto another layer moves all its effects there (replacing that layer's style, like Paste Layer Style), dragging one effect row moves just that effect (replacing an effect of the same kind), and holding Alt on release copies instead. The badge takes the drag the way the eye does, so clicks still select the row. The drop is one history step through a new engine command, layer.layerStyle.transferEffects (no menu entry, like Show All Effects). Tests: the engine command (copy, move one, move all, undo, bad params) and a Layers panel test that Alt-drags an effect row and drags the fx badge.
This commit is contained in:
1 parent
2515fa7cee
commit
b692ed451c
4 files changed
+288
-4
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||
use photocraft_algo::selection::Region;
|
||||
use photocraft_color::{BlendMode, ColorMode};
|
||||
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
|
||||
use photocraft_doc::{BlendIf, BlendRange, Document, Effect, Effects, Knockout, Layer, LayerContent, LayerId, LayerMask, SmartSource, StackMode};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
use serde_json::{Value, json};
|
||||
@@ -605,6 +605,68 @@ fn scale_effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Dragging a layer's fx onto another layer in the Layers panel: all its effects (or just the one
|
||||
/// at `effect`) move there, or with `copy` are copied. Moving all effects replaces the target's
|
||||
/// style, as pasting a layer style does; a single effect replaces the target's effect of the same
|
||||
/// kind, else is added.
|
||||
fn transfer_effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layer.layerStyle.transferEffects";
|
||||
let id_of = |key: &str| p.get(key).and_then(Value::as_u64).map(LayerId).ok_or_else(|| bad(CMD, format!("`{key}` must be a layer id")));
|
||||
let from = id_of("from")?;
|
||||
let to = id_of("to")?;
|
||||
if from == to {
|
||||
return Err(bad(CMD, "the effects are already on that layer"));
|
||||
}
|
||||
let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
let index = match p.get("effect") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "`effect` must be an effect index"))?),
|
||||
};
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let src = d.doc.layer(from).ok_or(EngineError::NoLayer(from))?;
|
||||
d.doc.layer(to).ok_or(EngineError::NoLayer(to))?;
|
||||
let payload = match index {
|
||||
Some(i) => {
|
||||
let e = src.effects.items.get(i).cloned().ok_or_else(|| bad(CMD, format!("the layer has no effect {i}")))?;
|
||||
Effects { enabled: true, items: vec![e], psd_raw: None, reference: None }
|
||||
}
|
||||
None => Effects { psd_raw: None, ..src.effects.clone() },
|
||||
};
|
||||
if payload.items.is_empty() {
|
||||
return Err(bad(CMD, "the layer has no layer effects"));
|
||||
}
|
||||
let label = if copy { "Copy Layer Effects" } else { "Move Layer Effects" };
|
||||
s.edit(label, |doc, _| {
|
||||
let dst = doc.layer_mut(to).ok_or(EngineError::NoLayer(to))?;
|
||||
dst.effects.psd_raw = None;
|
||||
if index.is_some() {
|
||||
dst.effects.enabled = true;
|
||||
for e in payload.items {
|
||||
let same = dst.effects.items.iter().position(|x| std::mem::discriminant(x) == std::mem::discriminant(&e));
|
||||
match same.and_then(|i| dst.effects.items.get_mut(i)) {
|
||||
Some(slot) => *slot = e,
|
||||
None => dst.effects.items.push(e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dst.effects = payload;
|
||||
}
|
||||
if !copy {
|
||||
let src = doc.layer_mut(from).ok_or(EngineError::NoLayer(from))?;
|
||||
match index {
|
||||
Some(i) if i < src.effects.items.len() => {
|
||||
src.effects.items.remove(i);
|
||||
src.effects.psd_raw = None;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => src.effects = Effects::default(),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"from": from.0, "to": to.0}))
|
||||
}
|
||||
|
||||
/// Effects drawn beneath the layer (they become separate layers below it, unclipped).
|
||||
fn is_below(e: &Effect) -> bool {
|
||||
match e {
|
||||
@@ -861,6 +923,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
spec!("layer.layerStyle.createLayer", "Create Layer", ["Layer", "Layer Style"], r##"{"layer":id?}"##, has_effects, create_layer),
|
||||
spec!("layer.layerStyle.scaleEffects", "Scale Effects…", ["Layer", "Layer Style"], r##"{"scale":1..1000=100}"##, has_effects, scale_effects),
|
||||
spec!(
|
||||
"layer.layerStyle.transferEffects",
|
||||
"Move Layer Effects",
|
||||
[],
|
||||
r##"{"from":id,"to":id,"effect":index? (default: all effects),"copy":bool=false}"##,
|
||||
has_doc,
|
||||
transfer_effects
|
||||
),
|
||||
CommandSpec {
|
||||
id: "layer.layerContentOptions",
|
||||
label: "Layer Content Options…",
|
||||
|
||||
@@ -526,3 +526,56 @@ fn load_files_into_stack_as_smart_object_then_median() {
|
||||
assert!((px[0] - 0.2).abs() < 2.0 / 255.0, "{px:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_effects_moves_or_copies_between_layers() {
|
||||
let mut s = session(8, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let a = active(&s).id;
|
||||
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
|
||||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff8000"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let b = active(&s).id;
|
||||
let layer = |s: &Session, id| s.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
let labels = |l: &Layer| l.effects.items.iter().map(Effect::label).collect::<Vec<_>>();
|
||||
let both = labels(&layer(&s, a));
|
||||
assert_eq!(both.len(), 2);
|
||||
|
||||
// Alt-drag of the whole fx row copies: both layers have the style.
|
||||
s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": b.0, "copy": true})).unwrap();
|
||||
assert_eq!(labels(&layer(&s, a)), both);
|
||||
assert_eq!(labels(&layer(&s, b)), both);
|
||||
|
||||
// Dragging one effect row moves just that effect.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let c = active(&s).id;
|
||||
s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": c.0, "effect": 1})).unwrap();
|
||||
assert_eq!(labels(&layer(&s, a)), both[..1]);
|
||||
assert_eq!(labels(&layer(&s, c)), both[1..]);
|
||||
|
||||
// Dragging the whole fx row moves everything and leaves the source without a style.
|
||||
s.execute("layer.layerStyle.transferEffects", json!({"from": b.0, "to": a.0})).unwrap();
|
||||
assert!(layer(&s, b).effects.items.is_empty());
|
||||
assert_eq!(labels(&layer(&s, a)), both);
|
||||
|
||||
// One history step per drop.
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("layer.layerStyle.transferEffects", json!({"from": a.0, "to": b.0})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(labels(&layer(&s, a)), both);
|
||||
assert!(layer(&s, b).effects.items.is_empty());
|
||||
|
||||
// Bad input is an error and changes nothing.
|
||||
for bad in [
|
||||
json!({"from": a.0, "to": a.0}),
|
||||
json!({"from": a.0}),
|
||||
json!({"from": a.0, "to": 9999}),
|
||||
json!({"from": a.0, "to": b.0, "effect": 7}),
|
||||
json!({"from": a.0, "to": b.0, "effect": -1}),
|
||||
json!({"from": b.0, "to": a.0}),
|
||||
] {
|
||||
assert!(s.execute("layer.layerStyle.transferEffects", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! #1719: layer effects are dragged onto another layer in the Layers panel, as in Photoshop: an
|
||||
//! effect row or the fx badge moves its effects there, ⌥-drag copies them.
|
||||
|
||||
use egui::{Modifiers, PointerButton, Pos2, Rect, accesskit::Role, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::layer_row_ui::{Indicator, RowRects, recorded};
|
||||
|
||||
/// `a` with a Drop Shadow and a Color Overlay, and `b` above it without effects.
|
||||
fn harness() -> (Harness<'static, PhotocraftApp>, LayerId, LayerId) {
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
|
||||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff8000"})).unwrap();
|
||||
let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
});
|
||||
h.run_steps(8);
|
||||
(h, LayerId(a), LayerId(b))
|
||||
}
|
||||
|
||||
fn row(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> RowRects {
|
||||
recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap()
|
||||
}
|
||||
|
||||
/// The effect row `name` under layer `id`'s row.
|
||||
fn effect_row(h: &Harness<'_, PhotocraftApp>, id: LayerId, name: &str) -> Rect {
|
||||
let layer = row(h, id).row;
|
||||
h.query_all_by_role(Role::Button)
|
||||
.filter(|n| n.accesskit_node().label().as_deref() == Some(name))
|
||||
.map(|n| n.rect())
|
||||
.find(|r| r.top() >= layer.bottom() - 1.0 && r.left() < layer.center().x && r.right() > layer.center().x)
|
||||
.unwrap_or_else(|| panic!("no {name} row under the layer"))
|
||||
}
|
||||
|
||||
fn effects(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> Vec<&'static str> {
|
||||
h.state().session.active().unwrap().doc.layer(id).unwrap().effects.items.iter().map(photocraft_doc::Effect::label).collect()
|
||||
}
|
||||
|
||||
/// A real press, drag and release, `modifiers` held throughout.
|
||||
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
|
||||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers });
|
||||
h.run_steps(1);
|
||||
for i in 1..=6 {
|
||||
h.hover_at(from + (to - from) * (i as f32 / 6.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers });
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_effects_onto_a_layer_moves_them_and_alt_copies() {
|
||||
let (mut h, a, b) = harness();
|
||||
assert_eq!(effects(&h, a), ["Drop Shadow", "Color Overlay"]);
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
|
||||
// ⌥-drag one effect row onto `b`: just that effect is copied.
|
||||
let from = effect_row(&h, a, "Color Overlay").center();
|
||||
drag(&mut h, from, row(&h, b).row.center(), Modifiers::ALT);
|
||||
assert_eq!(effects(&h, a), ["Drop Shadow", "Color Overlay"]);
|
||||
assert_eq!(effects(&h, b), ["Color Overlay"]);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one history step");
|
||||
|
||||
// Drag the fx badge onto `b`: all of `a`'s effects move there, replacing `b`'s.
|
||||
let badge = row(&h, a).indicators.into_iter().find(|(k, _)| *k == Indicator::Fx).unwrap().1;
|
||||
drag(&mut h, badge.center(), row(&h, b).row.center(), Modifiers::NONE);
|
||||
assert!(effects(&h, a).is_empty(), "the effects moved off `a`");
|
||||
assert_eq!(effects(&h, b), ["Drop Shadow", "Color Overlay"]);
|
||||
// The drag moved effects, not the layer: `b` is still above `a`.
|
||||
assert!(row(&h, b).row.top() < row(&h, a).row.top());
|
||||
}
|
||||
@@ -1523,9 +1523,14 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
.min_scrolled_height(if fill { rows_h } else { 0.0 })
|
||||
.auto_shrink([false, !fill])
|
||||
.show(ui, |ui| {
|
||||
// The drag key is set only by actual layer-row drags, not clicks or
|
||||
// The drag keys are set only by actual layer-row and fx drags, not clicks or
|
||||
// ordinary scrolling. The ScrollArea applies this to its own content.
|
||||
let dragging = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag"))).is_some() && ctx.input(|i| i.pointer.primary_down());
|
||||
// An fx drag that never saw its release (the panel was hidden) must not outlive the button.
|
||||
if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) {
|
||||
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
|
||||
}
|
||||
let held = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag")).is_some() || d.get_temp::<FxDrag>(fx_drag_key()).is_some());
|
||||
let dragging = held && ctx.input(|i| i.pointer.primary_down());
|
||||
let pointer = ctx.input(|i| i.pointer.interact_pos());
|
||||
let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt));
|
||||
if delta != 0.0 {
|
||||
@@ -1581,9 +1586,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let in_selection = selection.iter().any(|s| s.0 == id);
|
||||
(id, in_selection)
|
||||
});
|
||||
fx_drag_feedback(&ctx, &doc);
|
||||
// End any layer drag after every row has had a chance to accept the drop.
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
ctx.data_mut(|d| d.remove::<u64>(egui::Id::new("layer-drag")));
|
||||
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
@@ -1803,6 +1810,7 @@ fn layer_row(
|
||||
let row_h = if t.pro { 32.0 } else { 46.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
|
||||
layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions);
|
||||
fx_drop(ctx, ui, l, rect, actions);
|
||||
if resp.drag_started() {
|
||||
crate::layer_transfer::begin_from_panel(app, ctx, l.id);
|
||||
}
|
||||
@@ -1894,6 +1902,13 @@ fn layer_row(
|
||||
// Right-hand indicators first; the name gets what is left and ends in "…" (#144).
|
||||
let fx_open = app.session.active().is_none_or(|d| !d.fx_collapsed.contains(&l.id));
|
||||
let (name_right, indicators, fx_toggled) = crate::layer_row_ui::indicators(ui, &painter, rect, x, l, fx_open, actions);
|
||||
// Dragging the fx badge drags all the layer's effects, not the layer (Photoshop). Like the eye,
|
||||
// it takes the drag from the row; clicks still reach the row.
|
||||
if let Some(&(_, badge)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Fx)
|
||||
&& ui.interact(badge, ui.id().with(("fx-badge", l.id.0)), Sense::drag()).drag_started()
|
||||
{
|
||||
start_fx_drag(ctx, l.id, None);
|
||||
}
|
||||
let name_color = if l.visible { t.text } else { t.text_faint };
|
||||
let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) };
|
||||
// Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright.
|
||||
@@ -2746,6 +2761,60 @@ fn layer_drag_and_drop(
|
||||
}
|
||||
}
|
||||
|
||||
/// An fx row being dragged: the layer it belongs to and the effect index (`None` = the "Effects"
|
||||
/// row, all of them).
|
||||
type FxDrag = (u64, Option<usize>);
|
||||
|
||||
fn fx_drag_key() -> egui::Id {
|
||||
egui::Id::new("fx-drag")
|
||||
}
|
||||
|
||||
/// Start dragging the effects of `layer`: one (`effect`) or all of them.
|
||||
fn start_fx_drag(ctx: &egui::Context, layer: LayerId, effect: Option<usize>) {
|
||||
ctx.data_mut(|d| d.insert_temp::<FxDrag>(fx_drag_key(), (layer.0, effect)));
|
||||
}
|
||||
|
||||
/// While effects are dragged: their name by the pointer, and the copy cursor while ⌥ is held.
|
||||
fn fx_drag_feedback(ctx: &egui::Context, doc: &photocraft_doc::Document) {
|
||||
let Some((from, effect)) = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key())) else { return };
|
||||
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||||
let label = match effect {
|
||||
Some(i) => doc.layer(LayerId(from)).and_then(|l| l.effects.items.get(i)).map_or("", |e| e.label()),
|
||||
None => "Effects",
|
||||
};
|
||||
crate::layer_transfer::ghost(ctx, p, label);
|
||||
if ctx.input(|i| i.modifiers.alt) {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Copy);
|
||||
}
|
||||
}
|
||||
|
||||
/// The command for dropping effects of layer `from` on `target`: they move there, or with ⌥ held
|
||||
/// on release are copied (Photoshop). `effect` is one effect row, or all effects when `None`.
|
||||
fn fx_drop_action(from: u64, effect: Option<usize>, target: LayerId, copy: bool) -> (String, Value) {
|
||||
let mut payload = json!({"from": from, "to": target.0, "copy": copy});
|
||||
if let Some(o) = payload.as_object_mut()
|
||||
&& let Some(i) = effect
|
||||
{
|
||||
o.insert("effect".into(), json!(i));
|
||||
}
|
||||
("layer.layerStyle.transferEffects".into(), payload)
|
||||
}
|
||||
|
||||
/// A layer row accepts the fx row being dragged: outlined while the pointer is over it, dropped
|
||||
/// on release.
|
||||
fn fx_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) {
|
||||
let Some((from, effect)) = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key())) else { return };
|
||||
let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||||
if from == l.id.0 || !rect.contains(p) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
actions.push(fx_drop_action(from, effect, l.id, ctx.input(|i| i.modifiers.alt)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop shows a layer's effects as indented sub-rows ("Effects", then each effect).
|
||||
fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usize) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -2756,7 +2825,11 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi
|
||||
rows.push((e.label().to_string(), e.enabled(), kind));
|
||||
}
|
||||
for (i, (name, on, kind)) in rows.into_iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
|
||||
// Drag the row onto another layer to move its effects there, ⌥-drag to copy them.
|
||||
if resp.drag_started() {
|
||||
start_fx_drag(ui.ctx(), l.id, i.checked_sub(1));
|
||||
}
|
||||
if !ui.is_rect_visible(rect) {
|
||||
continue;
|
||||
}
|
||||
@@ -3082,6 +3155,10 @@ mod history_transform_tests {
|
||||
#[path = "layer_pct_slider_tests.rs"]
|
||||
mod layer_pct_slider_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fx_drag_tests.rs"]
|
||||
mod fx_drag_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lock_tests {
|
||||
use super::*;
|
||||
|
||||
Reference in new issue
Block a user