mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-23 07:34:04 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ce5770c06 |
No files matched your search
@@ -0,0 +1,274 @@
|
||||
//! Photoshop's on-canvas brush resize (#231): with a painting tool, Control+Alt+drag (Windows,
|
||||
//! Linux) or Control+Option+drag (macOS) changes the brush instead of painting. Left and right
|
||||
//! set the diameter (the circle's edge follows the pointer), up and down the hardness (down makes
|
||||
//! it harder). The brush circle stays where the drag began, tinted to show the hardness, with a
|
||||
//! Diameter/Hardness readout beside the pointer.
|
||||
//!
|
||||
//! The change is `tools.setBrush` calls sharing one `coalesce` key, so the whole drag is one
|
||||
//! journal entry (Rule 1) and, the brush being tool state, no history step. Krita's ⇧+drag (the
|
||||
//! request in #231) is not used: ⇧ is Photoshop's straight-line modifier for painting tools.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use egui::{Color32, Pos2, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::{ToolEvent, ViewXform};
|
||||
use crate::state::Tool;
|
||||
|
||||
/// Screen points of vertical drag that take the hardness from 0 % to 100 %.
|
||||
pub const HARDNESS_TRAVEL: f32 = 200.0;
|
||||
/// The brush size range (`tools.setBrush` accepts the same).
|
||||
const SIZE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
|
||||
|
||||
/// A resize drag in progress.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Resize {
|
||||
/// Where the drag began, in document pixels: the circle stays there.
|
||||
pub anchor: [f64; 2],
|
||||
/// The brush's diameter and hardness when it began.
|
||||
pub start: (f32, f32),
|
||||
/// The `coalesce` key of this drag's `tools.setBrush` calls.
|
||||
key: u64,
|
||||
}
|
||||
|
||||
/// Tools the gesture resizes: every painting tool, and Quick Selection (it has a brush too).
|
||||
pub fn applies(tool: Tool) -> bool {
|
||||
tool.is_brushlike() || tool == Tool::QuickSelection
|
||||
}
|
||||
|
||||
/// Control+Alt (Control+Option on a Mac): `ctrl` is the Control key on every platform.
|
||||
pub fn is_gesture(mods: egui::Modifiers) -> bool {
|
||||
mods.ctrl && mods.alt
|
||||
}
|
||||
|
||||
/// The diameter and hardness a drag from `start` by (`dx`, `dy`) document pixels gives at `zoom`.
|
||||
pub fn resized(start: (f32, f32), dx: f64, dy: f64, zoom: f32) -> (f32, f32) {
|
||||
let dx = if dx.is_finite() { dx as f32 } else { 0.0 };
|
||||
let dy = if dy.is_finite() { dy as f32 } else { 0.0 };
|
||||
let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 };
|
||||
let size = (start.0 + 2.0 * dx).round().clamp(*SIZE.start(), *SIZE.end());
|
||||
let hardness = (start.1 + dy * zoom / HARDNESS_TRAVEL).clamp(0.0, 1.0);
|
||||
// Whole percent, like the readout and the options bar.
|
||||
(size, (hardness * 100.0).round() / 100.0)
|
||||
}
|
||||
|
||||
/// Route a tool event: true when it belongs to a resize drag (begun here with Control+Alt held,
|
||||
/// or already in progress), so the tool never sees it.
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool {
|
||||
static KEY: AtomicU64 = AtomicU64::new(0);
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } => {
|
||||
if app.drag.is_some() || !applies(app.ui.tool) || !is_gesture(mods) {
|
||||
return false;
|
||||
}
|
||||
let b = &app.session.tools.brush;
|
||||
app.brush_resize = Some(Resize { anchor: [x, y], start: (b.size, b.hardness), key: KEY.fetch_add(1, Ordering::Relaxed) });
|
||||
true
|
||||
}
|
||||
ToolEvent::Move { x, y, .. } => match app.brush_resize {
|
||||
Some(r) => {
|
||||
update(app, r, x, y);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
ToolEvent::Up { x, y } => match app.brush_resize.take() {
|
||||
Some(r) => {
|
||||
update(app, r, x, y);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) {
|
||||
// Right is bigger on screen, also in a flipped view (View › Flip Horizontal).
|
||||
let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 };
|
||||
let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.current_zoom());
|
||||
let b = &app.session.tools.brush;
|
||||
if b.size == size && b.hardness == hardness {
|
||||
return;
|
||||
}
|
||||
let p = json!({ "brush": { "size": size, "hardness": hardness }, "coalesce": format!("brush-resize:{}", r.key) });
|
||||
if let Err(e) = app.run("tools.setBrush", p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the resize feedback: the brush circle at the anchor, filled with a red tip preview whose
|
||||
/// solid core shows the hardness (Photoshop's), and the readout beside the pointer. Returns true
|
||||
/// while a resize is in progress (the canvas then hides its cursor).
|
||||
pub fn draw(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) -> bool {
|
||||
let Some(r) = app.brush_resize else { return false };
|
||||
let b = &app.session.tools.brush;
|
||||
let c = xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32);
|
||||
let radius = (b.size / 2.0 * xf.zoom).max(1.0);
|
||||
tip_preview(painter, c, radius, b.hardness, Color32::from_rgba_unmultiplied(255, 0, 0, 110));
|
||||
painter.circle_stroke(c, radius + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
|
||||
painter.circle_stroke(c, radius, Stroke::new(1.0, Color32::from_white_alpha(220)));
|
||||
// Photoshop hides the pointer while the circle stays put.
|
||||
painter.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
let at = painter.ctx().pointer_latest_pos().unwrap_or(c);
|
||||
crate::canvas::draw_readout(
|
||||
painter.ctx(),
|
||||
"brush-resize-readout",
|
||||
at,
|
||||
["Diameter:", "Hardness:"],
|
||||
[format!("{} px", b.size.round() as i64), format!("{}%", (b.hardness * 100.0).round() as i64)],
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// A round tip of `radius` screen points: solid out to `hardness` of the radius, fading to clear
|
||||
/// at the edge. One mesh (two rings and a centre), so its cost doesn't grow with the brush.
|
||||
fn tip_preview(painter: &egui::Painter, c: Pos2, radius: f32, hardness: f32, color: Color32) {
|
||||
let clip = painter.clip_rect();
|
||||
if !clip.expand(radius).contains(c) {
|
||||
return;
|
||||
}
|
||||
// Enough segments for a smooth edge at any size, capped so huge brushes stay cheap.
|
||||
let n = ((radius * 0.5) as usize).clamp(24, 160);
|
||||
let inner = radius * hardness.clamp(0.0, 1.0);
|
||||
let clear = Color32::TRANSPARENT;
|
||||
let mut mesh = egui::Mesh::default();
|
||||
mesh.colored_vertex(c, color);
|
||||
for k in 0..n {
|
||||
let a = k as f32 / n as f32 * std::f32::consts::TAU;
|
||||
let d = vec2(a.cos(), a.sin());
|
||||
mesh.colored_vertex(c + d * inner, color);
|
||||
mesh.colored_vertex(c + d * radius, clear);
|
||||
}
|
||||
let n = n as u32;
|
||||
for k in 0..n {
|
||||
let (i0, o0) = (1 + 2 * k, 2 + 2 * k);
|
||||
let (i1, o1) = (1 + 2 * ((k + 1) % n), 2 + 2 * ((k + 1) % n));
|
||||
mesh.add_triangle(0, i0, i1);
|
||||
mesh.add_triangle(i0, o0, o1);
|
||||
mesh.add_triangle(i0, o1, i1);
|
||||
}
|
||||
painter.add(mesh);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::canvas::tool_event;
|
||||
|
||||
const CTRL_ALT: Modifiers = Modifiers { alt: true, ctrl: true, shift: false, mac_cmd: false, command: false };
|
||||
|
||||
fn app(tool: Tool) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200, "background": "transparent"})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 40, "hardness": 0.2}})).unwrap();
|
||||
app.ui.tool = tool;
|
||||
app
|
||||
}
|
||||
|
||||
fn history(app: &PhotocraftApp) -> usize {
|
||||
app.session.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_is_bounded() {
|
||||
assert_eq!(resized((40.0, 0.5), 10.0, 0.0, 1.0), (60.0, 0.5), "the edge follows the pointer");
|
||||
assert_eq!(resized((40.0, 0.5), 0.0, 50.0, 2.0), (40.0, 1.0), "down is harder, in screen points");
|
||||
assert_eq!(resized((40.0, 0.5), -1e9, -1e9, 1.0), (1.0, 0.0));
|
||||
assert_eq!(resized((40.0, 0.5), 1e9, 1e9, 1.0), (5000.0, 1.0));
|
||||
assert_eq!(resized((40.0, 0.5), f64::NAN, f64::INFINITY, f32::NAN), (40.0, 0.5));
|
||||
assert!(is_gesture(CTRL_ALT) && !is_gesture(Modifiers::ALT) && !is_gesture(Modifiers::CTRL) && !is_gesture(Modifiers::SHIFT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_drag_resizes_every_brush_tool_without_painting_or_history() {
|
||||
let tools: Vec<Tool> = Tool::ALL.into_iter().filter(|t| applies(*t)).collect();
|
||||
assert!(tools.len() > 10 && tools.contains(&Tool::Brush) && tools.contains(&Tool::Eraser) && tools.contains(&Tool::CloneStamp));
|
||||
for tool in tools {
|
||||
let mut app = app(tool);
|
||||
let (h0, j0) = (history(&app), app.session.journal.len());
|
||||
tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, CTRL_ALT);
|
||||
assert!(app.brush_resize.is_some() && app.drag.is_none(), "{tool:?}");
|
||||
// Right grows the size.
|
||||
tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 100.0, pressure: 1.0 }, CTRL_ALT);
|
||||
assert_eq!(app.session.tools.brush.size, 100.0, "{tool:?}");
|
||||
// Down raises the hardness (at 100 %, 200 points take it from 0 to 100 %).
|
||||
tool_event(&mut app, ToolEvent::Move { x: 130.0, y: 160.0, pressure: 1.0 }, CTRL_ALT);
|
||||
assert_eq!(app.session.tools.brush.hardness, 0.5, "{tool:?}");
|
||||
// Letting go of the keys mid-drag keeps resizing until the button is released.
|
||||
tool_event(&mut app, ToolEvent::Up { x: 120.0, y: 180.0 }, Modifiers::NONE);
|
||||
let b = &app.session.tools.brush;
|
||||
assert_eq!((b.size, b.hardness), (80.0, 0.6), "{tool:?}");
|
||||
assert!(app.brush_resize.is_none() && app.drag.is_none());
|
||||
// No history step, no paint: one coalesced tools.setBrush in the journal.
|
||||
assert_eq!(history(&app), h0, "{tool:?}");
|
||||
let new: Vec<&str> = app.session.journal[j0..].iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(new, ["tools.setBrush"], "{tool:?}");
|
||||
assert_eq!(app.session.journal.last().unwrap().1["brush"], json!({"size": 80.0f32, "hardness": 0.6f32}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_tools_and_strokes_in_progress_are_untouched() {
|
||||
let mut a = app(Tool::Move);
|
||||
tool_event(&mut a, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, CTRL_ALT);
|
||||
assert!(a.brush_resize.is_none());
|
||||
tool_event(&mut a, ToolEvent::Up { x: 10.0, y: 10.0 }, CTRL_ALT);
|
||||
let mut a = app(Tool::Brush);
|
||||
tool_event(&mut a, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, Modifiers::NONE);
|
||||
tool_event(&mut a, ToolEvent::Move { x: 50.0, y: 10.0, pressure: 1.0 }, CTRL_ALT);
|
||||
assert!(a.brush_resize.is_none(), "a stroke in progress isn't taken over");
|
||||
tool_event(&mut a, ToolEvent::Up { x: 50.0, y: 10.0 }, CTRL_ALT);
|
||||
assert!(a.session.journal.iter().any(|(id, _)| id == "paint.stroke"));
|
||||
assert_eq!(a.session.tools.brush.size, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_drag_shows_the_circle_and_readout() {
|
||||
let mut a = app(Tool::Brush);
|
||||
a.sync_views();
|
||||
let mut h = Harness::builder().with_size(vec2(1000.0, 700.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));
|
||||
},
|
||||
a,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::default());
|
||||
h.run_steps(4);
|
||||
let c = h.state().last_canvas_rect.center();
|
||||
let press = |h: &mut Harness<'static, PhotocraftApp>, pos: Pos2, pressed: bool| {
|
||||
h.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: CTRL_ALT });
|
||||
h.run();
|
||||
};
|
||||
h.event(egui::Event::ModifiersChanged(CTRL_ALT));
|
||||
h.event(egui::Event::PointerMoved(c));
|
||||
h.run();
|
||||
press(&mut h, c, true);
|
||||
let size0 = h.state().session.tools.brush.size;
|
||||
let undo0 = history(h.state());
|
||||
for k in 1..=6 {
|
||||
h.event(egui::Event::PointerMoved(c + vec2(10.0 * k as f32, 8.0 * k as f32)));
|
||||
h.run();
|
||||
}
|
||||
assert!(h.state().brush_resize.is_some());
|
||||
assert!(h.state().session.tools.brush.size > size0, "dragging right grew the brush");
|
||||
assert!(h.state().session.tools.brush.hardness > 0.2, "dragging down hardened it");
|
||||
assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("brush-resize-readout"))).is_some(), "the readout shows mid-drag");
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the pointer hides");
|
||||
press(&mut h, c + vec2(60.0, 48.0), false);
|
||||
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().brush_resize.is_none());
|
||||
assert!(!h.state().session.journal.iter().any(|(id, _)| id == "paint.stroke"), "nothing painted");
|
||||
assert_eq!(history(h.state()), undo0, "no undo step");
|
||||
}
|
||||
}
|
||||
@@ -96,35 +96,32 @@ pub fn marquee_readout(r: [f64; 4]) -> [String; 2] {
|
||||
/// Draw the marquee size readout below-right of the cursor (kept on screen), like Photoshop's:
|
||||
/// two rows, `W:` / `H:` labels on the left and the values right-aligned.
|
||||
fn draw_marquee_readout(ctx: &egui::Context, cursor: Pos2, values: [String; 2]) {
|
||||
draw_readout(ctx, "marquee-readout", cursor, ["W:", "H:"], values);
|
||||
}
|
||||
|
||||
/// A two-row readout beside the pointer (labels left, values right-aligned), above everything.
|
||||
pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor: Pos2, labels: [&str; 2], values: [String; 2]) {
|
||||
let t = crate::theme::Tokens::get(ctx);
|
||||
let font = egui::FontId::proportional(11.5);
|
||||
let labels = ["W:", "H:"];
|
||||
let width = |text: &str| ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), t.text).size().x);
|
||||
let (lw, vw) = (labels.map(width), [width(&values[0]), width(&values[1])]);
|
||||
let (label_col, value_col) = (lw[0].max(lw[1]), vw[0].max(vw[1]));
|
||||
egui::Area::new(egui::Id::new("marquee-readout"))
|
||||
.order(egui::Order::Tooltip)
|
||||
.fixed_pos(cursor + vec2(16.0, 18.0))
|
||||
.interactable(false)
|
||||
.constrain(true)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::new()
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.card_border))
|
||||
.corner_radius(t.radius_sm)
|
||||
.inner_margin(egui::Margin::symmetric(7, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
|
||||
for (i, value) in values.into_iter().enumerate() {
|
||||
// Labels left, values right-aligned on one edge, sharing a baseline.
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend());
|
||||
ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::Area::new(egui::Id::new(id)).order(egui::Order::Tooltip).fixed_pos(cursor + vec2(16.0, 18.0)).interactable(false).constrain(true).show(ctx, |ui| {
|
||||
egui::Frame::new().fill(t.card).stroke(Stroke::new(1.0, t.card_border)).corner_radius(t.radius_sm).inner_margin(egui::Margin::symmetric(7, 4)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
|
||||
for (i, value) in values.into_iter().enumerate() {
|
||||
// Labels left, values right-aligned on one edge, sharing a baseline.
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend());
|
||||
ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]);
|
||||
ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend());
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of
|
||||
@@ -1402,6 +1399,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
}
|
||||
draw_drag_preview(app, &painter, &xf);
|
||||
crate::zoom_tool::draw(&ctx, &painter);
|
||||
let resizing = crate::brush_resize::draw(app, &painter, &xf);
|
||||
draw_transform_controls(app, &painter, &xf);
|
||||
crate::paint_mouse::show_picker(app, &ctx);
|
||||
crate::snap_ui::draw(app, &painter, &xf);
|
||||
@@ -1430,6 +1428,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
} else if let Some(p) = response.hover_pos() {
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
let icon = match tool {
|
||||
// Resizing the brush: the circle stays where the drag began (`brush_resize`).
|
||||
t if resizing && crate::brush_resize::applies(t) => egui::CursorIcon::None,
|
||||
t if t.is_brushlike() || t == Tool::QuickSelection => {
|
||||
// Preferences › Cursors: brush tip outline (normal = the 50% contour, or
|
||||
// full size), precise crosshair, or the standard pointer.
|
||||
@@ -1768,6 +1768,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
|
||||
let mods = crate::workspace_ui::sticky_mods(app, mods);
|
||||
// Control+Alt-drag with a painting tool resizes the brush instead of painting (#231).
|
||||
if crate::brush_resize::pointer(app, ev, mods) {
|
||||
return;
|
||||
}
|
||||
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
|
||||
let ev = crate::move_mods::filter_event(app, ev, mods);
|
||||
// Ruler, Count and Note tools.
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod artboard_ui;
|
||||
pub mod brush_panel;
|
||||
pub mod brush_picker;
|
||||
pub mod brush_preview;
|
||||
pub mod brush_resize;
|
||||
pub mod brush_sections;
|
||||
pub mod brushes_tab;
|
||||
pub mod camera_raw_ui;
|
||||
@@ -223,6 +224,8 @@ pub struct PhotocraftApp {
|
||||
secondary_erase: bool,
|
||||
/// End of the last painting stroke: ⇧-click draws a straight line from it (#178).
|
||||
last_stroke_end: Option<(DocId, [f64; 2])>,
|
||||
/// Control+Alt-drag brush resize in progress (`brush_resize`, #231).
|
||||
pub(crate) brush_resize: Option<brush_resize::Resize>,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
|
||||
/// Screenshots not yet requested from the viewport: (token, earliest time in ms, frames seen).
|
||||
@@ -339,6 +342,7 @@ impl PhotocraftApp {
|
||||
move_preview: None,
|
||||
secondary_erase: false,
|
||||
last_stroke_end: None,
|
||||
brush_resize: None,
|
||||
control_rx: None,
|
||||
pending_screenshots: Vec::new(),
|
||||
queued_screenshots: Vec::new(),
|
||||
|
||||
Reference in new issue
Block a user