1use std::sync::Arc;
2
3use tokio::runtime::Runtime;
4use tokio::sync::mpsc;
5
6use opcua::types::NodeId;
7
8use crate::client::{UaClient, parse_variant};
9use crate::messages::{UiAction, UiUpdate};
10use crate::model::{AppModel, ConnectionState, DetailTab, MethodCallState};
11use crate::types::{AuthSpec, EndpointInfo, ValueTree};
12
13#[derive(Debug, Clone, Copy)]
14pub enum FilePickTarget {
15 CertPath,
16 KeyPath,
17}
18
19pub trait FrontendCtx: Clone + Send + Sync + 'static {
20 fn request_repaint(&self);
21 fn set_clipboard(&self, text: &str);
22 fn pick_file(
23 &self,
24 rt: &Runtime,
25 update_tx: &mpsc::UnboundedSender<UiUpdate>,
26 target: FilePickTarget,
27 title: &str,
28 default_dir: &str,
29 );
30}
31
32pub struct Engine {
33 pub model: AppModel,
34 pub client: Arc<UaClient>,
35 pub rt: Runtime,
36 pub update_tx: mpsc::UnboundedSender<UiUpdate>,
37}
38
39impl Engine {
40 pub fn new(
41 rt: Runtime,
42 log_rx: mpsc::UnboundedReceiver<UiUpdate>,
43 ) -> (Self, mpsc::UnboundedReceiver<UiUpdate>) {
44 let (update_tx, update_rx) = mpsc::unbounded_channel();
45 forward_logs(log_rx, update_tx.clone());
46 let engine = Self {
47 model: AppModel::default(),
48 client: Arc::new(UaClient::new()),
49 rt,
50 update_tx,
51 };
52 (engine, update_rx)
53 }
54
55 pub fn apply_update<C: FrontendCtx>(&mut self, ctx: &C, update: UiUpdate) {
56 match update {
57 UiUpdate::ConnectStarted => self.model.connection = ConnectionState::Connecting,
58 UiUpdate::ConnectFinished(Ok(())) => {
59 self.model.connection = ConnectionState::Connected;
60 self.model.record_successful_connection();
61 tracing::info!("connected to {}", self.model.endpoint_url);
62 let saved = self
63 .model
64 .last_selection_paths
65 .get(&self.model.endpoint_url)
66 .cloned();
67 match saved {
68 Some(path) if !path.is_empty() => {
69 tracing::info!(
70 "restoring previous selection ({} ancestors)",
71 path.len()
72 );
73 self.spawn_restore_selection(ctx, path);
74 }
75 _ => {
76 let root = self.model.root_node.clone();
77 self.ensure_expanded(ctx, root);
78 }
79 }
80 }
81 UiUpdate::ConnectFinished(Err(e)) => {
82 self.model.connection = ConnectionState::Disconnected;
83 tracing::error!("connect failed: {e}");
84 }
85 UiUpdate::DisconnectStarted => self.model.connection = ConnectionState::Disconnecting,
86 UiUpdate::DisconnectFinished => {
87 self.model.connection = ConnectionState::Disconnected;
88 self.model.reset_session_state();
89 tracing::info!("disconnected");
90 }
91 UiUpdate::ChildrenLoaded { parent, children } => {
92 self.model.tree.loading.remove(&parent);
93 match children {
94 Ok(c) => {
95 self.model.tree.children.insert(parent.clone(), c);
96 self.model.tree.expanded.insert(parent);
97 }
98 Err(e) => tracing::error!("browse {parent} failed: {e}"),
99 }
100 }
101 UiUpdate::SummaryLoaded { node, summary } => {
102 if self.model.selected.as_ref() == Some(&node) {
103 match summary {
104 Ok(s) => self.model.node_summary = Some(s),
105 Err(e) => tracing::error!("read summary {node} failed: {e}"),
106 }
107 }
108 }
109 UiUpdate::ReferencesLoaded { node, refs } => {
110 if self.model.selected.as_ref() == Some(&node) {
111 self.model.references_loading = false;
112 match refs {
113 Ok(rs) => self.model.references = Some(rs),
114 Err(e) => tracing::error!("browse refs {node} failed: {e}"),
115 }
116 }
117 }
118 UiUpdate::SelectionPathResolved { url, path } => {
119 self.model.last_selection_paths.insert(url, path);
120 }
121 UiUpdate::RestoreSelection(node) => {
122 self.model.selected = Some(node.clone());
123 self.spawn_node_summary(ctx, node.clone());
124 if self.model.active_tab == DetailTab::References {
125 self.spawn_browse_references(ctx, node);
126 }
127 }
128 UiUpdate::PathReady { node, path } => match path {
129 Ok(p) => {
130 ctx.set_clipboard(&p);
131 tracing::info!("copied path: {p}");
132 }
133 Err(e) => tracing::error!("path for {node} failed: {e}"),
134 },
135 UiUpdate::CertPathPicked(p) => self.model.auth_cert_path = p,
136 UiUpdate::KeyPathPicked(p) => self.model.auth_key_path = p,
137 UiUpdate::FilePickerClosed => self.model.file_picker_open = false,
138 UiUpdate::EndpointsDiscovered { url, result } => {
139 if url != self.model.endpoint_url {
140 tracing::debug!("dropping endpoints result for stale url {url}");
141 } else {
142 self.model.endpoints_loading = false;
143 match result {
144 Ok(eps) => {
145 tracing::info!("discovered {} endpoint(s)", eps.len());
146 self.model.discovered_endpoints = Some(eps);
147 self.select_first_matching_endpoint();
148 }
149 Err(e) => {
150 tracing::error!("endpoint discovery failed: {e}");
151 self.model.discovered_endpoints = Some(Vec::new());
152 }
153 }
154 }
155 }
156 UiUpdate::MethodSignatureLoaded { node, result } => {
157 if !self.method_call_targets(&node) {
158 return;
159 }
160 match result {
161 Ok(signature) => {
162 let n_inputs = signature.inputs.len();
163 self.model.method_call = Some(MethodCallState::Inputs {
164 node,
165 signature,
166 edited: vec![String::new(); n_inputs],
167 field_errors: vec![None; n_inputs],
168 call_error: None,
169 });
170 }
171 Err(error) => {
172 tracing::error!("read method signature {node} failed: {error}");
173 self.model.method_call =
174 Some(MethodCallState::Failed { node, error });
175 }
176 }
177 }
178 UiUpdate::MethodCallFinished { node, result } => {
179 if !self.method_call_targets(&node) {
180 return;
181 }
182 let Some(MethodCallState::Calling {
183 node, signature, edited,
184 }) = self.model.method_call.take()
185 else {
186 return;
187 };
188 match result {
189 Ok(outcome) => {
190 self.model.method_call = Some(MethodCallState::Result {
191 node,
192 signature,
193 edited,
194 outcome,
195 });
196 }
197 Err(error) => {
198 tracing::error!("call method {node} failed: {error}");
199 let n_inputs = signature.inputs.len();
200 self.model.method_call = Some(MethodCallState::Inputs {
201 node,
202 signature,
203 edited,
204 field_errors: vec![None; n_inputs],
205 call_error: Some(error),
206 });
207 }
208 }
209 }
210 UiUpdate::Log(line) => self.model.push_log(line),
211 }
212 }
213
214 fn method_call_targets(&self, node: &NodeId) -> bool {
215 self.model
216 .method_call
217 .as_ref()
218 .map(|s| s.node() == node)
219 .unwrap_or(false)
220 }
221
222 pub fn dispatch<C: FrontendCtx>(&mut self, ctx: &C, action: UiAction) {
223 match action {
224 UiAction::EndpointEdited(s) => {
225 if s != self.model.endpoint_url {
226 self.model.endpoint_url = s;
227 self.model.discovered_endpoints = None;
228 self.model.selected_endpoint = None;
229 self.model.endpoints_loading = false;
230 }
231 }
232 UiAction::TabSelected(t) => {
233 self.model.active_tab = t;
234 if t == DetailTab::References
235 && let Some(node) = self.model.selected.clone()
236 && self.model.references.is_none()
237 && !self.model.references_loading
238 {
239 self.spawn_browse_references(ctx, node);
240 }
241 }
242 UiAction::ConnectClicked => {
243 if self.model.selected_endpoint.is_none() {
244 tracing::info!("no endpoint selected; opening picker");
245 self.open_endpoint_picker(ctx);
246 } else {
247 let ep = self.model.selected_endpoint.as_ref().unwrap();
248 tracing::info!(
249 "connecting with {} / {}",
250 ep.security_policy,
251 ep.security_mode.label()
252 );
253 self.spawn_connect(ctx);
254 }
255 }
256 UiAction::DisconnectClicked => self.spawn_disconnect(ctx),
257 UiAction::NodeToggleExpand(n) => self.toggle_expand(ctx, n),
258 UiAction::NodeSelected(n) => self.select_node(ctx, n),
259 UiAction::ClearSelection => {
260 self.model.selected = None;
261 self.model.node_summary = None;
262 self.model.references = None;
263 self.model.references_loading = false;
264 }
265 UiAction::RefreshClicked => {
266 if let Some(node) = self.model.selected.clone() {
267 self.spawn_node_summary(ctx, node.clone());
268 if self.model.active_tab == DetailTab::References {
269 self.spawn_browse_references(ctx, node);
270 }
271 }
272 }
273 UiAction::OpenEndpointPicker => {
274 self.open_endpoint_picker(ctx);
275 }
276 UiAction::CloseEndpointPicker => {
277 self.model.endpoints_dialog_open = false;
278 }
279 UiAction::ForceRefreshEndpoints => {
280 if !self.model.endpoints_loading {
281 self.spawn_discover_endpoints(ctx);
282 }
283 }
284 UiAction::SelectEndpoint(ep) => {
285 self.model.selected_endpoint = Some(ep);
286 }
287 UiAction::ClearSelectedEndpoint => {
288 self.model.selected_endpoint = None;
289 }
290 UiAction::SetAuthMode(mode) => self.model.auth_mode = mode,
291 UiAction::SetEndpointModeFilter(mode) => {
292 self.model.endpoint_mode_filter = mode;
293 self.select_first_matching_endpoint();
294 }
295 UiAction::AuthUsernameEdited(s) => self.model.auth_username = s,
296 UiAction::AuthPasswordEdited(s) => self.model.auth_password = s,
297 UiAction::AuthCertPathEdited(s) => self.model.auth_cert_path = s,
298 UiAction::AuthKeyPathEdited(s) => self.model.auth_key_path = s,
299 UiAction::PickAuthCertPath => {
300 if !self.model.file_picker_open {
301 self.model.file_picker_open = true;
302 let default_dir = self.model.auth_cert_path.clone();
303 ctx.pick_file(
304 &self.rt,
305 &self.update_tx,
306 FilePickTarget::CertPath,
307 "Pick client certificate",
308 &default_dir,
309 );
310 }
311 }
312 UiAction::PickAuthKeyPath => {
313 if !self.model.file_picker_open {
314 self.model.file_picker_open = true;
315 let default_dir = self.model.auth_key_path.clone();
316 ctx.pick_file(
317 &self.rt,
318 &self.update_tx,
319 FilePickTarget::KeyPath,
320 "Pick private key",
321 &default_dir,
322 );
323 }
324 }
325 UiAction::CopyPath(node) => self.spawn_browse_path(ctx, node),
326 UiAction::CopyNodeId(node) => {
327 let text = node.to_string();
328 ctx.set_clipboard(&text);
329 tracing::info!("copied node id: {text}");
330 }
331 UiAction::CopyNodeValue => {
332 let Some(summary) = self.model.node_summary.as_ref() else {
333 tracing::warn!("no node summary loaded; nothing to copy");
334 return;
335 };
336 match summary.attributes.iter().find(|a| a.name == "Value") {
337 Some(attr) => {
338 let text = render_value_for_clipboard(&attr.value);
339 ctx.set_clipboard(&text);
340 tracing::info!("copied value of {}", summary.node_id);
341 }
342 None => tracing::warn!(
343 "selected node {} has no Value attribute",
344 summary.node_id
345 ),
346 }
347 }
348 UiAction::ConfirmConnect => {
349 if self.model.selected_endpoint.is_some() {
350 self.model.endpoints_dialog_open = false;
351 self.spawn_connect(ctx);
352 } else {
353 tracing::warn!("ConfirmConnect with no endpoint selected");
354 }
355 }
356 UiAction::OpenMethodCall(node) => self.open_method_call(ctx, node),
357 UiAction::CloseMethodCall => {
358 self.model.method_call = None;
359 }
360 UiAction::MethodArgEdited { index, value } => match self.model.method_call.as_mut() {
361 Some(MethodCallState::Inputs { edited, call_error, field_errors, .. }) => {
362 if let Some(slot) = edited.get_mut(index) {
363 *slot = value;
364 *call_error = None;
365 if let Some(err_slot) = field_errors.get_mut(index) {
366 *err_slot = None;
367 }
368 }
369 }
370 Some(MethodCallState::Result { edited, .. }) => {
371 if let Some(slot) = edited.get_mut(index) {
372 *slot = value;
373 }
374 }
375 _ => {}
376 },
377 UiAction::CallMethodConfirmed => self.confirm_method_call(ctx),
378 }
379 }
380
381 fn open_method_call<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
382 self.model.method_call = Some(MethodCallState::Loading { node: node.clone() });
383 self.spawn_method_signature(ctx, node);
384 }
385
386 fn confirm_method_call<C: FrontendCtx>(&mut self, ctx: &C) {
387 let (node, signature, edited) = match self.model.method_call.as_ref() {
388 Some(MethodCallState::Inputs {
389 node, signature, edited, ..
390 })
391 | Some(MethodCallState::Result {
392 node, signature, edited, ..
393 }) => (node.clone(), signature.clone(), edited.clone()),
394 _ => return,
395 };
396
397 let mut variants = Vec::with_capacity(signature.inputs.len());
398 let mut field_errors = vec![None; signature.inputs.len()];
399 let mut any_error = false;
400 for (i, arg) in signature.inputs.iter().enumerate() {
401 let s = edited.get(i).cloned().unwrap_or_default();
402 match parse_variant(&s, &arg.data_type, arg.value_rank) {
403 Ok(v) => variants.push(v),
404 Err(e) => {
405 field_errors[i] = Some(e);
406 any_error = true;
407 }
408 }
409 }
410 if any_error {
411 self.model.method_call = Some(MethodCallState::Inputs {
412 node,
413 signature,
414 edited,
415 field_errors,
416 call_error: None,
417 });
418 return;
419 }
420
421 let parent = signature.parent_object.clone();
422 let method = signature.method_node.clone();
423 self.model.method_call = Some(MethodCallState::Calling {
424 node: node.clone(),
425 signature,
426 edited,
427 });
428 self.spawn_method_call(ctx, parent, method, variants, node);
429 }
430
431 fn spawn_method_signature<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
432 let client = self.client.clone();
433 let tx = self.update_tx.clone();
434 let ctx = ctx.clone();
435 self.rt.spawn(async move {
436 let result = client
437 .read_method_signature(&node)
438 .await
439 .map_err(|e| e.to_string());
440 let _ = tx.send(UiUpdate::MethodSignatureLoaded { node, result });
441 ctx.request_repaint();
442 });
443 }
444
445 fn spawn_method_call<C: FrontendCtx>(
446 &self,
447 ctx: &C,
448 parent: NodeId,
449 method: NodeId,
450 inputs: Vec<opcua::types::Variant>,
451 node: NodeId,
452 ) {
453 let client = self.client.clone();
454 let tx = self.update_tx.clone();
455 let ctx = ctx.clone();
456 self.rt.spawn(async move {
457 let result = client
458 .call_method(&parent, &method, inputs)
459 .await
460 .map_err(|e| e.to_string());
461 let _ = tx.send(UiUpdate::MethodCallFinished { node, result });
462 ctx.request_repaint();
463 });
464 }
465
466 fn toggle_expand<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
467 if self.model.tree.expanded.contains(&node) {
468 self.model.tree.expanded.remove(&node);
469 } else if self.model.tree.children.contains_key(&node) {
470 self.model.tree.expanded.insert(node);
471 } else if !self.model.tree.loading.contains(&node) {
472 self.model.tree.loading.insert(node.clone());
473 self.spawn_browse_children(ctx, node);
474 }
475 }
476
477 fn ensure_expanded<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
478 if self.model.tree.expanded.contains(&node) {
479 return;
480 }
481 if self.model.tree.children.contains_key(&node) {
482 self.model.tree.expanded.insert(node);
483 } else if !self.model.tree.loading.contains(&node) {
484 self.model.tree.loading.insert(node.clone());
485 self.spawn_browse_children(ctx, node);
486 }
487 }
488
489 fn select_node<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
490 self.model.selected = Some(node.clone());
491 self.model.node_summary = None;
492 self.model.references = None;
493 self.spawn_node_summary(ctx, node.clone());
494 if self.model.active_tab == DetailTab::References {
495 self.spawn_browse_references(ctx, node.clone());
496 }
497 self.spawn_resolve_path(ctx, node);
498 }
499
500 fn select_first_matching_endpoint(&mut self) {
501 if let Some(eps) = self.model.discovered_endpoints.as_ref() {
502 let mut filtered: Vec<&EndpointInfo> = eps
503 .iter()
504 .filter(|e| e.security_mode == self.model.endpoint_mode_filter)
505 .collect();
506 filtered.sort_by(|a, b| b.security_level.cmp(&a.security_level));
507 self.model.selected_endpoint = filtered.first().map(|&e| e.clone());
508 }
509 }
510
511 fn open_endpoint_picker<C: FrontendCtx>(&mut self, ctx: &C) {
512 self.model.endpoints_dialog_open = true;
513 if self.model.discovered_endpoints.is_none() && !self.model.endpoints_loading {
514 self.spawn_discover_endpoints(ctx);
515 }
516 }
517
518 fn spawn_resolve_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
519 let client = self.client.clone();
520 let tx = self.update_tx.clone();
521 let url = self.model.endpoint_url.clone();
522 let ctx = ctx.clone();
523 self.rt.spawn(async move {
524 match client.node_path(&node).await {
525 Ok(path) => {
526 let _ = tx.send(UiUpdate::SelectionPathResolved { url, path });
527 ctx.request_repaint();
528 }
529 Err(e) => tracing::debug!("node_path for {node} failed: {e}"),
530 }
531 });
532 }
533
534 pub fn navigate_to_textual_path<C: FrontendCtx>(&self, ctx: &C, path: String) {
535 let client = self.client.clone();
536 let tx = self.update_tx.clone();
537 let ctx = ctx.clone();
538 self.rt.spawn(async move {
539 let target = match client.resolve_browse_path(&path).await {
540 Ok(t) => t,
541 Err(e) => {
542 tracing::warn!("resolve path '{path}' failed: {e}");
543 return;
544 }
545 };
546 let chain = match client.node_path(&target).await {
547 Ok(c) => c,
548 Err(e) => {
549 tracing::warn!("node_path for '{path}' failed: {e}");
550 return;
551 }
552 };
553 if chain.is_empty() {
554 return;
555 }
556 let final_target = chain.last().cloned().unwrap();
557 for parent in chain.iter().take(chain.len() - 1) {
558 match client.browse_children(parent).await {
559 Ok(children) => {
560 let _ = tx.send(UiUpdate::ChildrenLoaded {
561 parent: parent.clone(),
562 children: Ok(children),
563 });
564 }
565 Err(e) => {
566 tracing::warn!("navigate: browse_children({parent}) failed: {e}");
567 ctx.request_repaint();
568 return;
569 }
570 }
571 }
572 let _ = tx.send(UiUpdate::RestoreSelection(final_target));
573 ctx.request_repaint();
574 });
575 }
576
577 fn spawn_restore_selection<C: FrontendCtx>(&self, ctx: &C, path: Vec<NodeId>) {
578 let client = self.client.clone();
579 let tx = self.update_tx.clone();
580 let ctx = ctx.clone();
581 self.rt.spawn(async move {
582 if path.is_empty() {
583 return;
584 }
585 let target = path.last().cloned().unwrap();
586 for parent in path.iter().take(path.len() - 1) {
587 match client.browse_children(parent).await {
588 Ok(children) => {
589 let _ = tx.send(UiUpdate::ChildrenLoaded {
590 parent: parent.clone(),
591 children: Ok(children),
592 });
593 }
594 Err(e) => {
595 tracing::warn!("restore: browse_children({parent}) failed: {e}");
596 ctx.request_repaint();
597 return;
598 }
599 }
600 }
601 let _ = tx.send(UiUpdate::RestoreSelection(target));
602 ctx.request_repaint();
603 });
604 }
605
606 fn spawn_connect<C: FrontendCtx>(&mut self, ctx: &C) {
607 let client = self.client.clone();
608 let tx = self.update_tx.clone();
609 let url = self.model.endpoint_url.clone();
610 let endpoint = self.model.selected_endpoint.clone();
611 let auth = AuthSpec {
612 mode: self.model.auth_mode,
613 username: self.model.auth_username.clone(),
614 password: self.model.auth_password.clone(),
615 cert_path: self.model.auth_cert_path.clone(),
616 key_path: self.model.auth_key_path.clone(),
617 };
618 let ctx = ctx.clone();
619 let _ = tx.send(UiUpdate::ConnectStarted);
620 self.rt.spawn(async move {
621 let r = client
622 .connect(&url, endpoint.as_ref(), &auth)
623 .await
624 .map_err(|e| e.to_string());
625 let _ = tx.send(UiUpdate::ConnectFinished(r));
626 ctx.request_repaint();
627 });
628 }
629
630 fn spawn_discover_endpoints<C: FrontendCtx>(&mut self, ctx: &C) {
631 self.model.endpoints_loading = true;
632 self.model.discovered_endpoints = None;
633 let client = self.client.clone();
634 let tx = self.update_tx.clone();
635 let url = self.model.endpoint_url.clone();
636 let ctx = ctx.clone();
637 self.rt.spawn(async move {
638 let r = client
639 .discover_endpoints(&url)
640 .await
641 .map_err(|e| e.to_string());
642 let _ = tx.send(UiUpdate::EndpointsDiscovered { url, result: r });
643 ctx.request_repaint();
644 });
645 }
646
647 fn spawn_disconnect<C: FrontendCtx>(&self, ctx: &C) {
648 let client = self.client.clone();
649 let tx = self.update_tx.clone();
650 let ctx = ctx.clone();
651 let _ = tx.send(UiUpdate::DisconnectStarted);
652 self.rt.spawn(async move {
653 if let Err(e) = client.disconnect().await {
654 tracing::warn!("disconnect: {e}");
655 }
656 let _ = tx.send(UiUpdate::DisconnectFinished);
657 ctx.request_repaint();
658 });
659 }
660
661 fn spawn_browse_children<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
662 let client = self.client.clone();
663 let tx = self.update_tx.clone();
664 let ctx = ctx.clone();
665 self.rt.spawn(async move {
666 let r = client.browse_children(&node).await.map_err(|e| e.to_string());
667 let _ = tx.send(UiUpdate::ChildrenLoaded {
668 parent: node,
669 children: r,
670 });
671 ctx.request_repaint();
672 });
673 }
674
675 fn spawn_node_summary<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
676 let client = self.client.clone();
677 let tx = self.update_tx.clone();
678 let ctx = ctx.clone();
679 self.rt.spawn(async move {
680 let r = client
681 .read_node_summary(&node)
682 .await
683 .map_err(|e| e.to_string());
684 let _ = tx.send(UiUpdate::SummaryLoaded { node, summary: r });
685 ctx.request_repaint();
686 });
687 }
688
689 fn spawn_browse_path<C: FrontendCtx>(&self, ctx: &C, node: NodeId) {
690 let client = self.client.clone();
691 let tx = self.update_tx.clone();
692 let ctx = ctx.clone();
693 self.rt.spawn(async move {
694 let r = client.browse_path(&node).await.map_err(|e| e.to_string());
695 let _ = tx.send(UiUpdate::PathReady { node, path: r });
696 ctx.request_repaint();
697 });
698 }
699
700 fn spawn_browse_references<C: FrontendCtx>(&mut self, ctx: &C, node: NodeId) {
701 self.model.references_loading = true;
702 let client = self.client.clone();
703 let tx = self.update_tx.clone();
704 let ctx = ctx.clone();
705 self.rt.spawn(async move {
706 let r = client
707 .browse_references(&node)
708 .await
709 .map_err(|e| e.to_string());
710 let _ = tx.send(UiUpdate::ReferencesLoaded { node, refs: r });
711 ctx.request_repaint();
712 });
713 }
714}
715
716fn render_value_for_clipboard(value: &ValueTree) -> String {
717 use std::fmt::Write as _;
718 fn render(v: &ValueTree, indent: usize, out: &mut String) {
719 let pad = " ".repeat(indent);
720 match v {
721 ValueTree::Null => {
722 let _ = write!(out, "{pad}<null>");
723 }
724 ValueTree::Leaf(s) => {
725 let _ = write!(out, "{pad}{s}");
726 }
727 ValueTree::Array(items) => {
728 for (i, item) in items.iter().enumerate() {
729 if i > 0 {
730 out.push('\n');
731 }
732 let _ = write!(out, "{pad}[{i}]");
733 out.push('\n');
734 render(item, indent + 1, out);
735 }
736 }
737 ValueTree::Object(fields) => {
738 for (i, (k, val)) in fields.iter().enumerate() {
739 if i > 0 {
740 out.push('\n');
741 }
742 let _ = write!(out, "{pad}{k}:");
743 out.push('\n');
744 render(val, indent + 1, out);
745 }
746 }
747 }
748 }
749 let mut out = String::new();
750 render(value, 0, &mut out);
751 out
752}
753
754fn forward_logs(
755 mut log_rx: mpsc::UnboundedReceiver<UiUpdate>,
756 update_tx: mpsc::UnboundedSender<UiUpdate>,
757) {
758 std::thread::spawn(move || {
759 while let Some(msg) = log_rx.blocking_recv() {
760 if update_tx.send(msg).is_err() {
761 break;
762 }
763 }
764 });
765}