Skip to content

Commit 66f8026

Browse files
committed
added robust clipboard paste
1 parent 2ac412c commit 66f8026

9 files changed

Lines changed: 536 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ wasm-bindgen = "0.2.118"
4343
wasm-bindgen-futures = "0.4.68"
4444
web-time = "1.1.0"
4545
web-sys = { version = "0.3.95", features = [
46+
"Clipboard",
4647
"Document",
4748
"Element",
4849
"HtmlCanvasElement",
Lines changed: 22 additions & 0 deletions
Loading

src/app.rs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use egui_plot::{
1212
Points as PlotPointsItem, VLine,
1313
};
1414

15+
mod clipboard_import;
1516
mod diagnostics;
1617
mod fit_worker;
1718
mod formula;
@@ -32,13 +33,14 @@ use self::formula::model_formula_info;
3233
#[cfg(not(target_arch = "wasm32"))]
3334
use self::formula::{formula_svg_bytes, formula_svg_uri};
3435
use self::i18n::{
35-
actions_icon_image, center_origin_icon_image, clear_icon_image, family_label, fit_icon_image,
36-
fit_to_content_icon_image, github_mark_image, language_flag_image, model_choice_label,
37-
open_formula_icon_image, optimization_loss_metric_label, origin_bottom_left_icon_image,
38-
panels_icon_image, param_init_method_disabled_label, param_init_method_label,
39-
param_init_method_name_en, redo_icon_image, replay_pause_icon_image, replay_play_icon_image,
40-
reset_icon_image, spline_extrapolation_label, spline_knot_strategy_label, spray_brush_label,
41-
stop_icon_image, tool_icon_image, tr, undo_icon_image, view_icon_image,
36+
actions_icon_image, center_origin_icon_image, clear_icon_image, clipboard_import_icon_image,
37+
family_label, fit_icon_image, fit_to_content_icon_image, github_mark_image,
38+
language_flag_image, model_choice_label, open_formula_icon_image,
39+
optimization_loss_metric_label, origin_bottom_left_icon_image, panels_icon_image,
40+
param_init_method_disabled_label, param_init_method_label, param_init_method_name_en,
41+
redo_icon_image, replay_pause_icon_image, replay_play_icon_image, reset_icon_image,
42+
spline_extrapolation_label, spline_knot_strategy_label, spray_brush_label, stop_icon_image,
43+
tool_icon_image, tr, undo_icon_image, view_icon_image,
4244
};
4345
use self::normalization::ParametricNormalization;
4446
use self::optimizer::{
@@ -54,7 +56,9 @@ use self::param_init::{
5456
};
5557
use self::plot_utils::{fit_bounds_for_content, plot_domain};
5658
use self::points_state::{ParsedPointsCache, PointsEditorState};
57-
use self::points_text::{parse_f64, parse_points_text_cache, points_to_text};
59+
use self::points_text::{
60+
parse_f64, parse_points_from_clipboard_text, parse_points_text_cache, points_to_text,
61+
};
5862
use self::replay::ReplayState;
5963
#[cfg(test)]
6064
use self::replay::{ReplayFrame, ReplayFramePayload};
@@ -86,6 +90,8 @@ use crate::fit::{IncrementalSplineFitRunner, IncrementalSplineFitStep};
8690
use std::sync::atomic::{AtomicBool, Ordering};
8791
#[cfg(not(target_arch = "wasm32"))]
8892
use std::sync::mpsc::{self, Receiver, TryRecvError};
93+
#[cfg(target_arch = "wasm32")]
94+
use std::{cell::RefCell, rc::Rc};
8995

9096
const PARAMETRIC_PLOT_SAMPLES: usize = 200;
9197
const C1_MIN: f64 = 1e-8;
@@ -109,6 +115,9 @@ const RIGHT_PANEL_MIN_WIDTH: f32 = 280.0;
109115
const POINTS_PARSE_DEBOUNCE_MS: u64 = 180;
110116
const POINTS_HISTORY_LIMIT: usize = 256;
111117
const POINTS_PARSE_ERROR_PREFIX: &str = "Points parse error: ";
118+
const CLIPBOARD_IMPORT_ERROR_PREFIX: &str = "Clipboard import error: ";
119+
#[cfg(not(target_arch = "wasm32"))]
120+
const CLIPBOARD_IMPORT_PASTE_TIMEOUT_MS: u64 = 1_500;
112121
const POINTS_POSITIVE_AXIS_EPS: f64 = 1e-6;
113122
const UI_CORNER_RADIUS: u8 = 6;
114123
const PANEL_INNER_MARGIN_X: i8 = 10;
@@ -701,6 +710,14 @@ enum WasmFitJob {
701710
/// Состояние и UI-логика интерактивного приложения для подгонки кривых.
702711
pub struct CurveFitApp {
703712
points: PointsEditorState,
713+
#[cfg(not(target_arch = "wasm32"))]
714+
clipboard_import_request_pending: bool,
715+
#[cfg(not(target_arch = "wasm32"))]
716+
clipboard_import_requested_at: Option<Instant>,
717+
#[cfg(target_arch = "wasm32")]
718+
clipboard_import_web_in_flight: bool,
719+
#[cfg(target_arch = "wasm32")]
720+
clipboard_import_web_result: Rc<RefCell<Option<Result<String, String>>>>,
704721
selected_model: ModelChoice,
705722
polynomial_degree: usize,
706723
parameter_inputs: Vec<String>,
@@ -1316,6 +1333,14 @@ impl Default for CurveFitApp {
13161333

13171334
Self {
13181335
points: PointsEditorState::default(),
1336+
#[cfg(not(target_arch = "wasm32"))]
1337+
clipboard_import_request_pending: false,
1338+
#[cfg(not(target_arch = "wasm32"))]
1339+
clipboard_import_requested_at: None,
1340+
#[cfg(target_arch = "wasm32")]
1341+
clipboard_import_web_in_flight: false,
1342+
#[cfg(target_arch = "wasm32")]
1343+
clipboard_import_web_result: Rc::new(RefCell::new(None)),
13191344
selected_model,
13201345
polynomial_degree,
13211346
parameter_inputs: params_to_input_strings(&selected_family.default_params()),
@@ -1395,6 +1420,7 @@ impl eframe::App for CurveFitApp {
13951420
Self::apply_visual_style(ctx);
13961421
self.poll_fit_worker(ctx);
13971422
self.tick_replay(ctx);
1423+
self.poll_points_clipboard_import(ctx);
13981424
self.maybe_refresh_points_cache_after_debounce();
13991425

14001426
if !self.fit_in_progress {

src/app/clipboard_import.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
use super::*;
2+
3+
impl CurveFitApp {
4+
pub(super) fn clipboard_import_in_progress(&self) -> bool {
5+
#[cfg(not(target_arch = "wasm32"))]
6+
{
7+
self.clipboard_import_request_pending
8+
}
9+
10+
#[cfg(target_arch = "wasm32")]
11+
{
12+
self.clipboard_import_web_in_flight
13+
}
14+
}
15+
16+
pub(super) fn request_points_clipboard_import(&mut self, ctx: &egui::Context) {
17+
if self.fit_in_progress {
18+
return;
19+
}
20+
21+
#[cfg(not(target_arch = "wasm32"))]
22+
{
23+
if self.clipboard_import_request_pending {
24+
return;
25+
}
26+
self.clipboard_import_request_pending = true;
27+
self.clipboard_import_requested_at = Some(Instant::now());
28+
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste);
29+
ctx.request_repaint();
30+
}
31+
32+
#[cfg(target_arch = "wasm32")]
33+
{
34+
if self.clipboard_import_web_in_flight {
35+
return;
36+
}
37+
self.clipboard_import_web_in_flight = true;
38+
self.clipboard_import_web_result.borrow_mut().take();
39+
40+
let ctx = ctx.clone();
41+
let result_slot = Rc::clone(&self.clipboard_import_web_result);
42+
wasm_bindgen_futures::spawn_local(async move {
43+
let result = read_text_from_web_clipboard().await;
44+
*result_slot.borrow_mut() = Some(result);
45+
ctx.request_repaint();
46+
});
47+
}
48+
}
49+
50+
pub(super) fn poll_points_clipboard_import(&mut self, ctx: &egui::Context) {
51+
#[cfg(target_arch = "wasm32")]
52+
let _ = ctx;
53+
54+
#[cfg(not(target_arch = "wasm32"))]
55+
{
56+
if self.clipboard_import_request_pending {
57+
if let Some(text) = take_requested_paste_event(ctx) {
58+
self.clipboard_import_request_pending = false;
59+
self.clipboard_import_requested_at = None;
60+
self.handle_points_clipboard_import_result(Ok(text));
61+
return;
62+
}
63+
64+
let timed_out = self
65+
.clipboard_import_requested_at
66+
.is_some_and(|requested_at| {
67+
Instant::now().saturating_duration_since(requested_at)
68+
>= Duration::from_millis(CLIPBOARD_IMPORT_PASTE_TIMEOUT_MS)
69+
});
70+
if timed_out {
71+
self.clipboard_import_request_pending = false;
72+
self.clipboard_import_requested_at = None;
73+
self.handle_points_clipboard_import_result(Err(
74+
"Clipboard is empty or unavailable".to_string(),
75+
));
76+
} else {
77+
ctx.request_repaint();
78+
}
79+
}
80+
}
81+
82+
#[cfg(target_arch = "wasm32")]
83+
{
84+
if !self.clipboard_import_web_in_flight {
85+
return;
86+
}
87+
88+
let maybe_result = self.clipboard_import_web_result.borrow_mut().take();
89+
if let Some(result) = maybe_result {
90+
self.clipboard_import_web_in_flight = false;
91+
self.handle_points_clipboard_import_result(result);
92+
}
93+
}
94+
}
95+
96+
pub(super) fn handle_points_clipboard_import_result(&mut self, result: Result<String, String>) {
97+
let text = match result {
98+
Ok(text) => text,
99+
Err(error) => {
100+
self.set_clipboard_import_error(error);
101+
return;
102+
}
103+
};
104+
105+
if text.trim().is_empty() {
106+
self.set_clipboard_import_error("Clipboard text is empty");
107+
return;
108+
}
109+
110+
if let Err(error) = self.import_points_from_clipboard_text(&text) {
111+
self.set_clipboard_import_error(error);
112+
return;
113+
}
114+
115+
self.status = Some(self.idle_status_after_points_edit());
116+
}
117+
118+
pub(super) fn import_points_from_clipboard_text(
119+
&mut self,
120+
text: &str,
121+
) -> Result<usize, String> {
122+
let points = parse_points_from_clipboard_text(text)?;
123+
let imported_count = points.len();
124+
self.write_points_text(&points, true);
125+
Ok(imported_count)
126+
}
127+
128+
fn set_clipboard_import_error(&mut self, message: impl AsRef<str>) {
129+
let message = message.as_ref();
130+
if message.starts_with(CLIPBOARD_IMPORT_ERROR_PREFIX) {
131+
self.status = Some(StatusMessage::Error(message.to_owned()));
132+
} else {
133+
self.status = Some(StatusMessage::Error(format!(
134+
"{CLIPBOARD_IMPORT_ERROR_PREFIX}{message}"
135+
)));
136+
}
137+
}
138+
}
139+
140+
#[cfg(not(target_arch = "wasm32"))]
141+
fn take_requested_paste_event(ctx: &egui::Context) -> Option<String> {
142+
ctx.input_mut(|input| {
143+
let event_index = input
144+
.events
145+
.iter()
146+
.position(|event| matches!(event, egui::Event::Paste(_)))?;
147+
match input.events.remove(event_index) {
148+
egui::Event::Paste(text) => Some(text),
149+
_ => None,
150+
}
151+
})
152+
}
153+
154+
#[cfg(target_arch = "wasm32")]
155+
async fn read_text_from_web_clipboard() -> Result<String, String> {
156+
use wasm_bindgen_futures::JsFuture;
157+
158+
let window = web_sys::window().ok_or_else(|| "Window is unavailable".to_string())?;
159+
let clipboard = window.navigator().clipboard();
160+
let text = JsFuture::from(clipboard.read_text())
161+
.await
162+
.map_err(|error| format!("Failed to read clipboard text: {error:?}"))?
163+
.as_string()
164+
.ok_or_else(|| "Clipboard did not return text content".to_string())?;
165+
166+
Ok(text)
167+
}

src/app/i18n.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ pub(super) fn actions_icon_image(tint: egui::Color32) -> egui::Image<'static> {
8080
tabler_icon!("../../assets/icons/tabler/dots.svg", tint)
8181
}
8282

83+
pub(super) fn clipboard_import_icon_image(tint: egui::Color32) -> egui::Image<'static> {
84+
tabler_icon!("../../assets/icons/tabler/clipboard-text.svg", tint)
85+
}
86+
8387
pub(super) fn reset_icon_image(tint: egui::Color32) -> egui::Image<'static> {
8488
tabler_icon!("../../assets/icons/tabler/restore.svg", tint)
8589
}

0 commit comments

Comments
 (0)