* feat(canvas): Caps Lock forces the precise crosshair for painting tools (#1758) * Caps Lock poll: back off for 5 s after an X error instead of reconnecting and warning every frame Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5630e112b4
commit
0e011a69dc
|
|
@ -43,7 +43,8 @@ ron = "0.12"
|
|||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
|
||||
core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link"] }
|
||||
# `event` (CGEventSource) is used for the Caps Lock poll (#1758).
|
||||
core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link", "event"] }
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
|
||||
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
|
|
@ -63,7 +64,7 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
|
|||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr"] }
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
|
||||
# Wayland desktop color selection through the existing XDG portal transport.
|
||||
zbus = "=5.19.0"
|
||||
# Already used by the portal stack; bounded waits let cancelling close the OS request.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
//! Caps Lock state, read from the OS once per frame so the canvas can show the precise crosshair
|
||||
//! for painting tools whatever the cursor preference (Photoshop's ⇪ behaviour, #1758).
|
||||
//!
|
||||
//! winit and egui have no Caps Lock key or modifier — `egui::Key` has no variant, `Modifiers` no
|
||||
//! field, and egui-winit's `key_from_named_key` drops the `NamedKey::CapsLock` key event before it
|
||||
//! reaches the app. So the state is polled here instead, from the platform:
|
||||
//!
|
||||
//! - **X11 (and XWayland)**: XKB's Lock modifier state, via the pure-Rust x11rb client. The app
|
||||
//! already opens on X11 when the `Preferences › Performance › Linux display server` preference
|
||||
//! asks for it, and on Wayland through XWayland for pen support, so this is the common path.
|
||||
//! - **Windows**: `GetKeyState(VK_CAPITAL)` through the safe `winsafe` binding.
|
||||
//! - **macOS**: `CGEventSourceFlagsState` (`kCGEventFlagAlphaShift`) through `core-graphics2`.
|
||||
//! - **Native Wayland**: there is no global keyboard-state query (no equivalent of XKB GetState or
|
||||
//! VK_CAPITAL); the service is `None` there and Caps Lock leaves the cursor preference alone.
|
||||
//!
|
||||
//! No `unsafe`: every OS call goes through a safe binding.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use core_graphics2::event_source::CGEventSource;
|
||||
use core_graphics2::event_types::{CGEventFlags, CGEventSourceStateID};
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
Some(Box::new(|| {
|
||||
let flags = CGEventSource::flags_state(CGEventSourceStateID::CombinedSessionState);
|
||||
flags.contains(CGEventFlags::MaskAlphaShift)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
Some(Box::new(|| {
|
||||
// The second element of the pair is "key toggled on", which is Caps Lock's state.
|
||||
let (_, toggled) = winsafe::GetKeyState(winsafe::co::VK::CAPITAL);
|
||||
toggled
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
use x11rb::protocol::xkb::{self, ConnectionExt as _};
|
||||
use x11rb::protocol::xproto::ModMask;
|
||||
use x11rb::rust_connection::RustConnection;
|
||||
|
||||
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
// XKB keyboard state lives on the X server. The window is our window: whether it is X11
|
||||
// (or XWayland) is whether there is an X server to ask. Native Wayland has no such
|
||||
// connection and degrades to the cursor preference (no service).
|
||||
let is_x11 = matches!(cc.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_));
|
||||
if !is_x11 {
|
||||
return None;
|
||||
}
|
||||
// Lazily opened on first use and kept: the Lock-modifier mask is stable per keymap, so it
|
||||
// is queried once and only the toggle state is re-read every frame.
|
||||
let mut conn: Option<(RustConnection, ModMask)> = None;
|
||||
// After a failure, wait before reconnecting (and warn once per failure), so a broken X
|
||||
// connection doesn't reconnect and log on every frame.
|
||||
let mut retry_at: Option<std::time::Instant> = None;
|
||||
Some(Box::new(move || {
|
||||
if retry_at.is_some_and(|t| std::time::Instant::now() < t) {
|
||||
return false;
|
||||
}
|
||||
match caps_lock_on(&mut conn) {
|
||||
Ok(on) => {
|
||||
retry_at = None;
|
||||
on
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("couldn't read the Caps Lock state: {error}");
|
||||
conn = None;
|
||||
retry_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
|
||||
false
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// Caps Lock is on when the Lock modifier is both engaged (`locked_mods`) and present in the
|
||||
/// keyboard's indicator map (so a re-mapped lock key is still recognised).
|
||||
fn caps_lock_on(conn: &mut Option<(RustConnection, ModMask)>) -> Result<bool, String> {
|
||||
if conn.is_none() {
|
||||
let (c, _) = RustConnection::connect(None).map_err(|e| e.to_string())?;
|
||||
// XKB requests need the extension active first (it is, on every modern server).
|
||||
let enabled = c.xkb_use_extension(1, 0).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
if !enabled.supported {
|
||||
return Err("the X server has no XKB extension".into());
|
||||
}
|
||||
let device = u16::from(xkb::ID::USE_CORE_KBD);
|
||||
// Indicator 0 is the Caps Lock indicator; its `.mods` is the Lock-modifier mask.
|
||||
let map = c.xkb_get_indicator_map(device, 1).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
let lock = map.maps.first().map_or(ModMask::LOCK, |m| m.mods);
|
||||
*conn = Some((c, lock));
|
||||
}
|
||||
let (conn, lock) = match conn.as_ref() {
|
||||
Some((conn, lock)) => (conn, *lock),
|
||||
None => return Err("no X connection".into()),
|
||||
};
|
||||
let device = u16::from(xkb::ID::USE_CORE_KBD);
|
||||
let state = conn.xkb_get_state(device).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
Ok(u16::from(state.locked_mods) & u16::from(lock) != 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::CapsLockFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CapsLockFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ mod app_dirs;
|
|||
mod app_icon;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod caps_lock;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod cursor;
|
||||
|
|
@ -444,6 +445,9 @@ fn main() -> eframe::Result {
|
|||
let _ = in_window_menus;
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
|
||||
// precise crosshair for painting tools while it is toggled on (#1758).
|
||||
app.services.caps_lock = caps_lock::service(cc);
|
||||
app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
|
||||
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
|
||||
// write into the stylus feed. The monitor goes in here, not before the event loop:
|
||||
|
|
|
|||
|
|
@ -2865,30 +2865,35 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
// full size), precise crosshair, or the standard pointer.
|
||||
use photocraft_engine::prefs::PaintingCursor;
|
||||
let cur = app.session.prefs().cursors.clone();
|
||||
let painting = app.drag.is_some();
|
||||
let brush = &app.session.tools.brush;
|
||||
let full = (brush.size / 2.0 * xf.zoom).max(1.0);
|
||||
let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full };
|
||||
match cur.painting {
|
||||
PaintingCursor::Standard => egui::CursorIcon::Default,
|
||||
PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0),
|
||||
_ if painting && cur.show_only_crosshair_while_painting => crate::tool_cursor::crosshair(&painter, p, 5.0, 0.0),
|
||||
// The Pencil: the square of whole pixels its dab fills, on the pixel grid.
|
||||
_ if tool == Tool::Pencil => {
|
||||
let ppp = painter.ctx().pixels_per_point();
|
||||
let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp);
|
||||
let px = 1.0 / ppp;
|
||||
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside);
|
||||
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside);
|
||||
// Too small to see where it is: the hotspot as well.
|
||||
if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 {
|
||||
crate::tool_cursor::crosshair(&painter, p, 4.0, 0.0);
|
||||
// ⇪ forces the precise crosshair, whatever the cursor preference (#1758).
|
||||
if app.caps_lock {
|
||||
crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0)
|
||||
} else {
|
||||
let painting = app.drag.is_some();
|
||||
let brush = &app.session.tools.brush;
|
||||
let full = (brush.size / 2.0 * xf.zoom).max(1.0);
|
||||
let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full };
|
||||
match cur.painting {
|
||||
PaintingCursor::Standard => egui::CursorIcon::Default,
|
||||
PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0),
|
||||
_ if painting && cur.show_only_crosshair_while_painting => crate::tool_cursor::crosshair(&painter, p, 5.0, 0.0),
|
||||
// The Pencil: the square of whole pixels its dab fills, on the pixel grid.
|
||||
_ if tool == Tool::Pencil => {
|
||||
let ppp = painter.ctx().pixels_per_point();
|
||||
let sq = pencil_cursor_rect(&xf, xf.to_doc(p), brush.size, ppp);
|
||||
let px = 1.0 / ppp;
|
||||
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_black_alpha(160)), egui::StrokeKind::Outside);
|
||||
painter.rect_stroke(sq, 0.0, Stroke::new(px, Color32::from_white_alpha(230)), egui::StrokeKind::Inside);
|
||||
// Too small to see where it is: the hotspot as well.
|
||||
if cur.show_crosshair_in_brush_tip || sq.width() < 6.0 {
|
||||
crate::tool_cursor::crosshair(&painter, p, 4.0, 0.0);
|
||||
}
|
||||
egui::CursorIcon::None
|
||||
}
|
||||
_ => {
|
||||
let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r);
|
||||
crate::tool_cursor::circle(&painter, p, r, centre)
|
||||
}
|
||||
egui::CursorIcon::None
|
||||
}
|
||||
_ => {
|
||||
let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r);
|
||||
crate::tool_cursor::circle(&painter, p, r, centre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
//! Caps Lock swaps the painting tools' cursor to the precise crosshair, exactly like Photoshop,
|
||||
//! whatever the Cursors › Painting preference is (#1758). The override changes only painting
|
||||
//! tools (Brush, Pencil, Clone Stamp, Healing, Eraser, …): Move, Marquee, Crop and Zoom keep
|
||||
//! their own cursors. Tests drive the real canvas (`document_area`) and compare the painted
|
||||
//! cursor that caps lock produces with the one the Precise preference draws — they must match,
|
||||
//! and the Standard preference (off) must not.
|
||||
|
||||
use egui::CursorIcon;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::Tool;
|
||||
|
||||
fn capslock_harness() -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 40, "hardness": 1.0}})).unwrap();
|
||||
app.sync_views();
|
||||
app.ui.extras.rulers = false;
|
||||
app.ui.tool = Tool::Brush;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
h.run_steps(4);
|
||||
// 100 %: one document pixel per point; the pointer hovers the canvas centre.
|
||||
let v = &mut h.state_mut().ui.views[0];
|
||||
v.zoom = 1.0;
|
||||
v.center = [200.0, 150.0];
|
||||
v.fit_pending = false;
|
||||
h.run_steps(2);
|
||||
let p = h.state().last_canvas_rect.center();
|
||||
h.event(egui::Event::PointerMoved(p));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Everything the cursor path can change in one frame: the cursor icon, the OS cursor bitmap
|
||||
/// (Windows) and the shapes painted on the canvas (the tool-cursor path paints the crosshair
|
||||
/// and the brush tip outline). Comparison across two preference/caps-lock states of the same
|
||||
/// harness is platform-independent.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct CursorFp {
|
||||
icon: CursorIcon,
|
||||
image: Option<[u16; 2]>,
|
||||
shapes: Vec<egui::epaint::ClippedShape>,
|
||||
}
|
||||
|
||||
fn fp(h: &Harness<'_, PhotocraftApp>) -> CursorFp {
|
||||
let out = h.output();
|
||||
CursorFp { icon: out.platform_output.cursor_icon, image: out.platform_output.cursor_image.as_ref().map(|i| i.size), shapes: out.shapes.clone() }
|
||||
}
|
||||
|
||||
fn painting(h: &mut Harness<'_, PhotocraftApp>, value: &str) {
|
||||
h.state_mut().run("prefs.set", json!({"path": "cursors.painting", "value": value})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Caps Lock is read from the OS by the app shell; tests toggle the per-frame flag directly.
|
||||
fn caps(h: &mut Harness<'_, PhotocraftApp>, on: bool) {
|
||||
h.state_mut().caps_lock = on;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_lock_forces_the_precise_crosshair_for_painting_tools_whatever_the_preference() {
|
||||
for tool in [
|
||||
Tool::Brush,
|
||||
Tool::Pencil,
|
||||
Tool::CloneStamp,
|
||||
Tool::Healing,
|
||||
Tool::Eraser,
|
||||
// The block that honours the painting preference also covers the Quick Selection.
|
||||
Tool::QuickSelection,
|
||||
] {
|
||||
let mut h = capslock_harness();
|
||||
// Each tool keeps its own brush (#218), so switch the tool before painting.
|
||||
h.state_mut().ui.tool = tool;
|
||||
h.run_steps(2);
|
||||
painting(&mut h, "standard");
|
||||
let off = fp(&h);
|
||||
painting(&mut h, "precise");
|
||||
let precise = fp(&h);
|
||||
painting(&mut h, "standard");
|
||||
caps(&mut h, true);
|
||||
let on = fp(&h);
|
||||
assert_ne!(off, precise, "{tool:?}: the Standard and Precise cursors must differ");
|
||||
assert_eq!(on, precise, "{tool:?}: caps lock must draw the same crosshair as the Precise preference");
|
||||
assert_ne!(on, off, "{tool:?}: caps lock must not keep the Standard cursor");
|
||||
caps(&mut h, false);
|
||||
assert_eq!(fp(&h), off, "{tool:?}: releasing caps lock restores the preference cursor");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_lock_leaves_non_painting_tools_cursors_alone() {
|
||||
for tool in [Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, Tool::Crop, Tool::Zoom, Tool::Hand] {
|
||||
let mut h = capslock_harness();
|
||||
h.state_mut().ui.tool = tool;
|
||||
h.run_steps(2);
|
||||
let off = fp(&h);
|
||||
caps(&mut h, true);
|
||||
let on = fp(&h);
|
||||
assert_eq!(on, off, "{tool:?}: caps lock must not change a non-painting cursor");
|
||||
caps(&mut h, false);
|
||||
}
|
||||
}
|
||||
|
|
@ -251,6 +251,9 @@ pub type OsEventsFn = Box<dyn FnMut() -> Vec<OsEvent>>;
|
|||
pub type QuitFn = Box<dyn FnMut()>;
|
||||
/// Where the OS pointer is now, in egui points within the window; `None` when unknown.
|
||||
pub type CursorPosFn = Box<dyn FnMut(&egui::Context) -> Option<egui::Pos2>>;
|
||||
/// Whether Caps Lock is toggled on, read from the OS. `None` where the platform cannot
|
||||
/// report it (native Wayland): the cursor then follows the cursor preference (#1758).
|
||||
pub type CapsLockFn = Box<dyn FnMut() -> bool>;
|
||||
|
||||
/// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of
|
||||
/// I/O dependencies.
|
||||
|
|
@ -307,6 +310,9 @@ pub struct Services {
|
|||
/// The pointer position read from the OS (desktop): winit 0.30's file drops carry none, and
|
||||
/// the window gets no pointer events during an OS drag (see `file_open::DropTarget`).
|
||||
pub cursor_pos: Option<CursorPosFn>,
|
||||
/// Caps Lock toggled on (desktop; `None` on Wayland and the web). Read once per frame, so
|
||||
/// the canvas can show the precise crosshair for painting tools, whatever the preference.
|
||||
pub caps_lock: Option<CapsLockFn>,
|
||||
/// The persistent brush preset store, loading in the background (desktop; see
|
||||
/// `photocraft_engine::preset_store`). Attached to the session once it arrives; without
|
||||
/// one, brush presets are session-only (web, tests).
|
||||
|
|
@ -375,6 +381,9 @@ pub struct PhotocraftApp {
|
|||
/// This press began with ⌥ (Alt) held on a painting tool, so it samples colours instead of
|
||||
/// painting until it is released (`canvas::alt_eyedropper`, #417).
|
||||
pub(crate) alt_sampling: bool,
|
||||
/// Caps Lock toggled on, read from the OS each frame (`services.caps_lock`): painting tools
|
||||
/// show the precise crosshair whatever the cursor preference (#1758).
|
||||
pub caps_lock: bool,
|
||||
/// The first digit of a two-digit opacity typed on the number keys (`opacity_keys`, #352).
|
||||
pub(crate) opacity_keys: opacity_keys::Pending,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
|
|
@ -544,6 +553,7 @@ impl PhotocraftApp {
|
|||
quick_pick: false,
|
||||
brush_resize_armed: false,
|
||||
alt_sampling: false,
|
||||
caps_lock: false,
|
||||
opacity_keys: None,
|
||||
control_rx: None,
|
||||
pending_screenshots: Vec::new(),
|
||||
|
|
@ -1073,6 +1083,9 @@ impl PhotocraftApp {
|
|||
impl eframe::App for PhotocraftApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language));
|
||||
// Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas
|
||||
// cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`.
|
||||
self.caps_lock = self.services.caps_lock.as_mut().is_some_and(|f| f());
|
||||
if !self.styled {
|
||||
Self::setup_context(ctx, self.ui.theme);
|
||||
self.styled = true;
|
||||
|
|
@ -1717,6 +1730,9 @@ mod blend_dropdown_wheel_tests;
|
|||
#[cfg(test)]
|
||||
mod marquee_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod caps_lock_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod stamp_tests;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue