Skip to main content

brep_app/automation/
queue.rs

1//! The command queue: the channel between a host and the app.
2//!
3//! A host calls [`AutomationQueue::submit`] with an [`Envelope`] and gets a
4//! receiver for the [`Reply`]. The app drains the queue at three fixed points
5//! in the frame (§4.4): [`drain_input`](AutomationQueue::drain_input) in
6//! `raw_input_hook`, then [`drain_app`](AutomationQueue::drain_app) for
7//! `Phase::Mutate` at the top of `ui` and for `Phase::Read` at the bottom. A
8//! command waits in the queue until its phase comes round.
9use crate::automation::command::{lookup, Ctx, Envelope, Handler, Notice, NoticeKind, Outcome, Phase, Reply};
10use crate::automation::pointer::Pointer;
11use std::collections::VecDeque;
12use std::sync::mpsc::{channel, Receiver, Sender};
13use std::sync::{Arc, Mutex};
14
15pub struct Pending {
16    pub envelope: Envelope,
17    pub reply: Sender<Reply>,
18}
19
20struct Awaiting {
21    token: u64,
22    region: crate::automation::cmd_capture::Region,
23    pending: Pending,
24}
25
26#[derive(Default)]
27struct Inner {
28    pending: VecDeque<Pending>,
29    awaiting: Vec<Awaiting>,
30    notices: Vec<Notice>,
31    /// How many of `notices` have already ridden out on a command REPLY.
32    /// See [`Inner::reply_notices`].
33    notices_replied: usize,
34    pointer: Pointer,
35    next_token: u64,
36    frame: u64,
37    poisoned: Option<String>,
38}
39
40/// Most notices kept. The app pushes one per toast whether or not a host is
41/// listening, so an ordinary session (the `automation` feature is on by
42/// default) must not grow a buffer for a reader that never arrives; the oldest
43/// go first, like the toast overlay's own cap.
44const MAX_NOTICES: usize = 256;
45
46impl Inner {
47    /// The notices a command REPLY carries: the ones that have not gone out on
48    /// an earlier reply.
49    ///
50    /// Two readers share this one buffer, and each must see every notice once.
51    /// A reply piggy-backs the new ones so an agent reads a refusal in the
52    /// answer to the call that caused it; the `notices` COMMAND drains the whole
53    /// buffer on its own cursor, so "every notice since the last drain" stays
54    /// true no matter how many other calls went by in between. Taking the buffer
55    /// for the replies — which is what this used to do — made `notices` report
56    /// whatever happened to arrive after the last call, which for a script that
57    /// waits before reading is nothing at all.
58    fn reply_notices(&mut self) -> Vec<Notice> {
59        let from = self.notices_replied.min(self.notices.len());
60        self.notices_replied = self.notices.len();
61        self.notices[from..].to_vec()
62    }
63}
64
65#[derive(Default)]
66pub struct AutomationQueue {
67    inner: Mutex<Inner>,
68    ctx: Mutex<Option<egui::Context>>,
69}
70
71impl AutomationQueue {
72    pub fn new() -> Arc<Self> {
73        Arc::new(Self::default())
74    }
75
76    /// Give the queue the egui context so a submit can wake an idle frame loop.
77    pub fn attach(&self, ctx: &egui::Context) {
78        *self.ctx.lock().unwrap() = Some(ctx.clone());
79    }
80
81    /// Enqueue a command. The reply arrives on the returned receiver once the
82    /// app has reached the command's phase (or at once, for an unknown command
83    /// or a poisoned session).
84    pub fn submit(&self, envelope: Envelope) -> Receiver<Reply> {
85        let (tx, rx) = channel();
86        let mut inner = self.inner.lock().unwrap();
87        if let Some(why) = &inner.poisoned {
88            let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("session poisoned: {why}"), vec![]));
89            return rx;
90        }
91        if lookup(&envelope.cmd).is_none() {
92            let _ = tx.send(Reply::err(envelope.id, inner.frame, format!("unknown command `{}`", envelope.cmd), vec![]));
93            return rx;
94        }
95        inner.pending.push_back(Pending { envelope, reply: tx });
96        drop(inner);
97        if let Some(ctx) = self.ctx.lock().unwrap().as_ref() {
98            ctx.request_repaint();
99        }
100        rx
101    }
102
103    /// Record something the session should know about — a toast the user would
104    /// have seen, a refusal, a panic. The app's own toast lane feeds this every
105    /// frame (`BrepApp::ui`), so `notices` reports what was on screen.
106    pub fn push_notice(&self, kind: NoticeKind, text: impl Into<String>) {
107        let mut inner = self.inner.lock().unwrap();
108        let frame = inner.frame;
109        inner.notices.push(Notice { kind, frame, text: text.into() });
110        let overflow = inner.notices.len().saturating_sub(MAX_NOTICES);
111        if overflow > 0 {
112            inner.notices.drain(0..overflow);
113            inner.notices_replied = inner.notices_replied.saturating_sub(overflow);
114        }
115    }
116
117    /// Drain for the `notices` COMMAND: everything since the last drain. The
118    /// reply cursor goes back to the start with them — the buffer is empty, so
119    /// the reply this drain rides out on carries nothing twice.
120    pub fn take_notices(&self) -> Vec<Notice> {
121        let mut inner = self.inner.lock().unwrap();
122        inner.notices_replied = 0;
123        std::mem::take(&mut inner.notices)
124    }
125
126    pub fn pointer(&self) -> Pointer {
127        self.inner.lock().unwrap().pointer.clone()
128    }
129
130    pub fn frame(&self) -> u64 {
131        self.inner.lock().unwrap().frame
132    }
133
134    pub fn has_pending(&self) -> bool {
135        let inner = self.inner.lock().unwrap();
136        !inner.pending.is_empty() || !inner.awaiting.is_empty()
137    }
138
139    /// Mark the session unusable (a panic unwound through the app). Every
140    /// pending and future command is answered with the reason.
141    pub fn poison(&self, why: impl Into<String>) {
142        let why = why.into();
143        let mut inner = self.inner.lock().unwrap();
144        inner.poisoned = Some(why.clone());
145        let frame = inner.frame;
146        for p in inner.pending.drain(..) {
147            let _ = p.reply.send(Reply::err(p.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
148        }
149        for a in inner.awaiting.drain(..) {
150            let _ = a.pending.reply.send(Reply::err(a.pending.envelope.id, frame, format!("session poisoned: {why}"), vec![]));
151        }
152    }
153
154    pub fn is_poisoned(&self) -> bool {
155        self.inner.lock().unwrap().poisoned.is_some()
156    }
157
158    /// Phase 1 of the frame: apply input commands as `egui::Event`s and
159    /// complete captures whose `Event::Screenshot` arrived. `view` is the
160    /// viewport rect of the last frame (for `region: viewport` crops).
161    /// `ctx` is the app's: the pointer reads egui's clock from it (the
162    /// headless host leaves `raw.time` unset, and egui adds `predicted_dt`),
163    /// and a `click_gap` resets its click history.
164    pub fn drain_input(&self, raw: &mut egui::RawInput, frame: u64, ppp: f32, view: Option<egui::Rect>, ctx: &egui::Context) {
165        let mut inner = self.inner.lock().unwrap();
166        inner.frame = frame;
167        inner.pointer.now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time) + f64::from(raw.predicted_dt));
168
169        // Screenshots that landed in this frame's input.
170        if !inner.awaiting.is_empty() {
171            let mut done = Vec::new();
172            for event in &raw.events {
173                if let egui::Event::Screenshot { user_data, image, .. } = event {
174                    let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied();
175                    if let Some(token) = token {
176                        if let Some(pos) = inner.awaiting.iter().position(|a| a.token == token) {
177                            let a = inner.awaiting.remove(pos);
178                            done.push((a, image.clone()));
179                        }
180                    }
181                }
182            }
183            for (a, image) in done {
184                let notices = inner.reply_notices();
185                let reply = match crate::automation::cmd_capture::encode_capture(&image, ppp, view, &a.region) {
186                    Ok((json, png)) => {
187                        let mut r = Reply::ok(a.pending.envelope.id, frame, json, notices);
188                        r.blob = Some(png);
189                        r
190                    }
191                    Err(e) => Reply::err(a.pending.envelope.id, frame, e, notices),
192                };
193                let _ = a.pending.reply.send(reply);
194            }
195        }
196
197        // Input-phase commands, in order.
198        let mut keep = VecDeque::new();
199        let mut events = Vec::new();
200        while let Some(p) = inner.pending.pop_front() {
201            let Some(spec) = lookup(&p.envelope.cmd) else { continue };
202            match (&spec.handler, spec.phase) {
203                (Handler::Input(f), Phase::Input) => {
204                    let result = f(&mut inner.pointer, p.envelope.args.clone(), &mut events);
205                    let notices = inner.reply_notices();
206                    let reply = match result {
207                        Ok(v) => Reply::ok(p.envelope.id, frame, v, notices),
208                        Err(e) => Reply::err(p.envelope.id, frame, e, notices),
209                    };
210                    let _ = p.reply.send(reply);
211                }
212                _ => keep.push_back(p),
213            }
214        }
215        inner.pending = keep;
216        // A `click_gap` this frame: a fresh `PointerState` before the pass
217        // begins, so egui has no click to count the next one against. The
218        // pass builds on the state this replaces (`InputState::begin_pass`).
219        if std::mem::take(&mut inner.pointer.reset_clicks) {
220            ctx.input_mut(|i| i.pointer = egui::PointerState::default());
221        }
222        raw.modifiers = inner.pointer.modifiers.egui();
223        raw.events.extend(events);
224    }
225
226    /// Phases 2 and 3 of the frame. Runs every pending command registered for
227    /// `phase`, in order.
228    pub fn drain_app(&self, phase: Phase, ctx: &mut Ctx<'_>, frame: u64) {
229        let batch: Vec<Pending> = {
230            let mut inner = self.inner.lock().unwrap();
231            inner.frame = frame;
232            let mut keep = VecDeque::new();
233            let mut batch = Vec::new();
234            while let Some(p) = inner.pending.pop_front() {
235                match lookup(&p.envelope.cmd) {
236                    Some(spec) if spec.phase == phase => batch.push(p),
237                    _ => keep.push_back(p),
238                }
239            }
240            inner.pending = keep;
241            batch
242        };
243        for p in batch {
244            let spec = lookup(&p.envelope.cmd).expect("checked at submit");
245            let Handler::App(f) = &spec.handler else { continue };
246            let before = refused_counts(&ctx.app.docs);
247            let result = f(ctx, p.envelope.args.clone());
248            let result = refused_by_lock(&ctx.app.docs, &before, result);
249            let mut inner = self.inner.lock().unwrap();
250            match result {
251                Ok(Outcome::Done(v)) => {
252                    let notices = inner.reply_notices();
253                    let _ = p.reply.send(Reply::ok(p.envelope.id, frame, v, notices));
254                }
255                Ok(Outcome::Blob { json, bytes }) => {
256                    let notices = inner.reply_notices();
257                    let mut r = Reply::ok(p.envelope.id, frame, json, notices);
258                    r.blob = Some(bytes);
259                    let _ = p.reply.send(r);
260                }
261                // No reply yet — the capture answers when its image arrives, and
262                // takes the notices then (the cursor must not move here).
263                Ok(Outcome::AwaitScreenshot { token, region }) => {
264                    inner.awaiting.push(Awaiting { token, region, pending: p });
265                }
266                Err(e) => {
267                    let notices = inner.reply_notices();
268                    let _ = p.reply.send(Reply::err(p.envelope.id, frame, e, notices));
269                }
270            }
271        }
272    }
273
274    /// A fresh token for a screenshot request.
275    pub fn next_token(&self) -> u64 {
276        let mut inner = self.inner.lock().unwrap();
277        inner.next_token += 1;
278        inner.next_token
279    }
280}
281
282/// Each open document's refused-edit count ([`crate::document::Document::access`]).
283fn refused_counts(docs: &crate::document::Documents) -> Vec<(u64, u64)> {
284    docs.iter().map(|d| (d.id(), d.engine.history.refused_user_edits())).collect()
285}
286
287/// A command that tried to change a read-only document (a PLM revision this
288/// user has not checked out, or a released one) did not change it — the
289/// document's history refused the write — so its reply is that refusal, not
290/// a success. A command that only reads, moves the camera, opens or closes a
291/// document refuses nothing and answers as it did.
292fn refused_by_lock(
293    docs: &crate::document::Documents,
294    before: &[(u64, u64)],
295    result: Result<Outcome, String>,
296) -> Result<Outcome, String> {
297    if result.is_err() {
298        return result;
299    }
300    for doc in docs.iter() {
301        let was = before.iter().find(|(id, _)| *id == doc.id()).map(|(_, n)| *n);
302        if was.is_some_and(|was| doc.engine.history.refused_user_edits() > was) {
303            if let crate::document::Access::ReadOnly { reason } = doc.access() {
304                return Err(format!("{} is read-only: {reason}", doc.title()));
305            }
306        }
307    }
308    result
309}
310