Skip to main content

ravenrs_sheet/engine/
windows.rs

1//! Windows on open workbooks.
2//!
3//! Every open workbook has one or more windows (View › New Window adds another on the same
4//! workbook). A window has its own selection, active sheet and per-sheet selections. The active
5//! window's view lives in its [`DocState`] (`selection`, `sheet_selections`) and in the workbook's
6//! active sheet ([`crate::model::workbook::Workbook::active_sheet`]), where every command reads
7//! it; each other window keeps its view in [`WindowState`] until it is activated. Swapping a view
8//! in or out never copies the workbook or makes it dirty: the active sheet is set in place through
9//! the shared `Arc`, and selections live outside the workbook.
10
11use serde_json::{Value as Json, json};
12
13use crate::engine::{DocState, EngineError, Result, Selection, Session};
14
15/// The view state one window keeps while another window on its workbook is showing.
16#[derive(Clone, Debug, Default, PartialEq)]
17pub struct WindowView {
18    pub selection: Selection,
19    pub sheet: usize,
20    /// The active sheet's name, so a sheet inserted, deleted or moved meanwhile in another
21    /// window still finds it.
22    pub sheet_name: String,
23    pub sheet_selections: Vec<Selection>,
24}
25
26/// One window on an open workbook.
27#[derive(Clone, Debug)]
28pub struct WindowState {
29    /// Identifies the window for the rest of the session; never reused.
30    pub id: u64,
31    /// The [`DocState::uid`] of the workbook it shows.
32    pub doc_uid: u64,
33    /// View › Hide.
34    pub hidden: bool,
35    /// `None` while this window's view is the one in the document (the active window, or the
36    /// workbook's most recently active window); exactly one window per workbook has `None`.
37    view: Option<WindowView>,
38    /// Activation order: higher was active more recently.
39    last_active: u64,
40}
41
42impl WindowView {
43    fn capture(d: &DocState) -> WindowView {
44        WindowView {
45            selection: d.selection.clone(),
46            sheet: d.wb.active_sheet(),
47            sheet_name: d.wb.sheets.get(d.wb.active_sheet()).map(|s| s.name.clone()).unwrap_or_default(),
48            sheet_selections: d.sheet_selections.clone(),
49        }
50    }
51
52    fn apply(self, d: &mut DocState) {
53        let sheets = &d.wb.sheets;
54        let at = if sheets.get(self.sheet).is_some_and(|s| s.name == self.sheet_name) {
55            self.sheet
56        } else {
57            sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.sheet)
58        };
59        d.wb.set_active_sheet(at.min(sheets.len().saturating_sub(1)));
60        d.selection = self.selection;
61        d.sheet_selections = self.sheet_selections;
62    }
63}
64
65/// Which window a command from a script or agent acts on.
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67pub enum Target {
68    /// The active window.
69    #[default]
70    Active,
71    /// A window by [`WindowState::id`].
72    Window(u64),
73    /// A workbook by [`DocState::uid`]: its most recently active window.
74    Doc(u64),
75}
76
77impl Target {
78    /// Reads `window` or `doc` from a JSON object (control channel envelopes, MCP arguments).
79    /// Neither present is [`Target::Active`]; a value that isn't an id is an error.
80    pub fn from_json(p: &Json) -> Result<Target> {
81        let id = |k: &str| -> Result<Option<u64>> {
82            match p.get(k) {
83                None | Some(Json::Null) => Ok(None),
84                Some(v) => v.as_u64().map(Some).ok_or_else(|| EngineError::Other(format!("`{k}` must be a window or workbook id number"))),
85            }
86        };
87        Ok(match (id("window")?, id("doc")?) {
88            (Some(w), _) => Target::Window(w),
89            (None, Some(d)) => Target::Doc(d),
90            (None, None) => Target::Active,
91        })
92    }
93}
94
95/// What [`Session::leave_target`] restores.
96#[derive(Debug)]
97#[must_use]
98pub struct TargetGuard {
99    id: u64,
100    back: Option<u64>,
101}
102
103impl Session {
104    /// The open windows, in the order they were opened.
105    pub fn windows(&self) -> &[WindowState] {
106        &self.windows
107    }
108
109    /// The active window's id (`None` with no workbook open).
110    pub fn active_window(&self) -> Option<u64> {
111        self.active_window
112    }
113
114    pub fn window(&self, id: u64) -> Option<&WindowState> {
115        self.windows.iter().find(|w| w.id == id)
116    }
117
118    /// A workbook's windows in opening order.
119    fn windows_of(&self, doc: u64) -> impl Iterator<Item = &WindowState> {
120        self.windows.iter().filter(move |w| w.doc_uid == doc)
121    }
122
123    /// The window title: the workbook's name when it has one window, "Name:1", "Name:2"… in
124    /// opening order when it has several.
125    pub fn window_title(&self, id: u64) -> Option<String> {
126        let w = self.window(id)?;
127        let d = self.docs.iter().find(|d| d.uid == w.doc_uid)?;
128        let name = d.display_title();
129        let mut n = 0;
130        let mut at = 0;
131        for x in self.windows_of(w.doc_uid) {
132            n += 1;
133            if x.id == id {
134                at = n;
135            }
136        }
137        Some(if n > 1 { format!("{name}:{at}") } else { name })
138    }
139
140    /// The most recently active window of a workbook, preferring one that isn't hidden.
141    pub fn doc_window(&self, doc: u64) -> Option<u64> {
142        self.windows_of(doc).max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id)
143    }
144
145    /// Resolves a [`Target`] to a window id.
146    pub fn target_window(&self, t: Target) -> Result<u64> {
147        match t {
148            Target::Active => self.active_window.ok_or(EngineError::NoDocument),
149            Target::Window(id) => self.window(id).map(|w| w.id).ok_or_else(|| EngineError::Other(format!("no open window has id {id}"))),
150            Target::Doc(uid) => self.doc_window(uid).ok_or_else(|| EngineError::Other(format!("no open workbook has id {uid}"))),
151        }
152    }
153
154    pub(crate) fn push_window(&mut self, doc_uid: u64) -> u64 {
155        self.next_window = self.next_window.saturating_add(1);
156        let id = self.next_window;
157        self.windows.push(WindowState { id, doc_uid, hidden: false, view: None, last_active: 0 });
158        id
159    }
160
161    /// Makes a window active, swapping its view into its workbook. Unknown ids are ignored.
162    pub fn activate_window(&mut self, id: u64) {
163        self.enter_window(id, true);
164    }
165
166    /// Makes a window the one commands act on while the interface draws it, without counting as
167    /// an activation: the most recently active window of each workbook stays the same.
168    pub fn show_window(&mut self, id: u64) {
169        self.enter_window(id, false);
170    }
171
172    fn enter_window(&mut self, id: u64, recent: bool) {
173        let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return };
174        let Some(w) = self.windows.get(wi) else { return };
175        let doc = w.doc_uid;
176        if w.view.is_some()
177            && let Some(di) = self.document_index(doc)
178        {
179            let live = self.windows.iter().position(|x| x.doc_uid == doc && x.view.is_none());
180            let incoming = self.windows.get_mut(wi).and_then(|w| w.view.take());
181            if let Some(d) = self.docs.get_mut(di) {
182                if let Some(li) = live
183                    && let Some(lw) = self.windows.get_mut(li)
184                {
185                    lw.view = Some(WindowView::capture(d));
186                }
187                if let Some(v) = incoming {
188                    v.apply(d);
189                }
190            }
191        }
192        if recent {
193            self.activation = self.activation.saturating_add(1);
194            let stamp = self.activation;
195            if let Some(w) = self.windows.get_mut(wi) {
196                w.last_active = stamp;
197            }
198        }
199        self.active_window = Some(id);
200        if let Some(di) = self.document_index(doc) {
201            self.active = di;
202        }
203    }
204
205    /// Activates the most recently active window that isn't hidden, else any window, else none.
206    pub(crate) fn activate_recent(&mut self) {
207        let next = self.windows.iter().max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id);
208        match next {
209            Some(id) => self.activate_window(id),
210            None => {
211                self.active_window = None;
212                self.active = 0;
213            }
214        }
215    }
216
217    /// View › New Window: another window on the active window's workbook, showing what it shows.
218    pub fn new_window(&mut self) -> Result<u64> {
219        let current = self.active_window.ok_or(EngineError::NoDocument)?;
220        let doc = self.window(current).map(|w| w.doc_uid).ok_or(EngineError::NoDocument)?;
221        let view = self.doc().map(WindowView::capture)?;
222        // The new window takes over the live view (identical at this point); the old one keeps a copy.
223        if let Some(w) = self.windows.iter_mut().find(|w| w.doc_uid == doc && w.view.is_none()) {
224            w.view = Some(view);
225        }
226        let id = self.push_window(doc);
227        self.activate_window(id);
228        Ok(id)
229    }
230
231    /// Closes one window. The workbook stays open while it has another window; closing its last
232    /// window closes the workbook (unsaved changes are the caller's to ask about).
233    pub fn close_window(&mut self, id: u64) {
234        let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return };
235        let doc = self.windows.get(wi).map(|w| w.doc_uid).unwrap_or_default();
236        if self.windows_of(doc).count() <= 1 {
237            if let Some(di) = self.document_index(doc) {
238                self.close_document(di);
239            }
240            return;
241        }
242        let closed = self.windows.remove(wi);
243        if closed.view.is_none() {
244            // Its view was the one in the document: the workbook's next window takes it over.
245            if let Some(next) = self.windows.iter().filter(|w| w.doc_uid == doc).max_by_key(|w| w.last_active).map(|w| w.id) {
246                let incoming = self.windows.iter_mut().find(|w| w.id == next).and_then(|w| w.view.take());
247                if let Some(v) = incoming
248                    && let Some(d) = self.docs.iter_mut().find(|d| d.uid == doc)
249                {
250                    v.apply(d);
251                }
252            }
253        }
254        if self.active_window == Some(id) {
255            self.activate_recent();
256        }
257    }
258
259    /// View › Hide on a window; the most recent visible window comes forward.
260    pub fn hide_window(&mut self, id: u64) {
261        let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return };
262        w.hidden = true;
263        if self.active_window == Some(id) && self.windows.iter().any(|w| !w.hidden) {
264            self.activate_recent();
265        }
266    }
267
268    /// View › Unhide: shows a hidden window and activates it.
269    pub fn unhide_window(&mut self, id: u64) {
270        let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return };
271        w.hidden = false;
272        self.activate_window(id);
273    }
274
275    /// The most recently hidden window.
276    pub fn last_hidden_window(&self) -> Option<u64> {
277        self.windows.iter().filter(|w| w.hidden).max_by_key(|w| w.last_active).map(|w| w.id)
278    }
279
280    /// The windows as scripts and the Switch Windows lists see them:
281    /// `[{window, doc, index, title, dirty, active, hidden}]`, `index` being the workbook's
282    /// position in [`Session::documents`].
283    pub fn window_list(&self) -> Json {
284        Json::Array(
285            self.windows
286                .iter()
287                .filter_map(|w| {
288                    let index = self.document_index(w.doc_uid)?;
289                    let d = self.docs.get(index)?;
290                    Some(json!({
291                        "window": w.id,
292                        "doc": w.doc_uid,
293                        "index": index,
294                        "title": self.window_title(w.id).unwrap_or_default(),
295                        "dirty": d.is_dirty(),
296                        "active": self.active_window == Some(w.id),
297                        "hidden": w.hidden,
298                    }))
299                })
300                .collect(),
301        )
302    }
303
304    /// Runs `f` with the target window active, then brings back the window that was active, so a
305    /// script acting on another window doesn't change what the user is looking at. When `f`
306    /// itself switched windows (`window.activate`, `file.new`, a close), its choice stands.
307    pub fn with_target<T>(&mut self, t: Target, f: impl FnOnce(&mut Session) -> T) -> Result<T> {
308        let guard = self.enter_target(t)?;
309        let r = f(self);
310        self.leave_target(guard);
311        Ok(r)
312    }
313
314    /// The first half of [`Session::with_target`], for callers whose closure can't borrow the
315    /// session alone (the UI runs commands through its app): activates the target window and
316    /// returns what [`Session::leave_target`] needs to bring the previous one back.
317    pub fn enter_target(&mut self, t: Target) -> Result<Option<TargetGuard>> {
318        if t == Target::Active {
319            return Ok(None);
320        }
321        let id = self.target_window(t)?;
322        let back = self.active_window;
323        self.activate_window(id);
324        Ok(Some(TargetGuard { id, back }))
325    }
326
327    pub fn leave_target(&mut self, guard: Option<TargetGuard>) {
328        let Some(TargetGuard { id, back: Some(back) }) = guard else { return };
329        if self.active_window == Some(id) && back != id && self.window(back).is_some() {
330            self.activate_window(back);
331        }
332    }
333
334    /// [`Session::execute`] on a target window.
335    pub fn execute_in(&mut self, t: Target, id: &str, params: Json) -> Result<Json> {
336        self.with_target(t, |s| s.execute(id, params))?
337    }
338
339    /// [`Session::execute_headless`] on a target window.
340    pub fn execute_headless_in(&mut self, t: Target, id: &str, params: Json) -> Result<Json> {
341        self.with_target(t, |s| s.execute_headless(id, params))?
342    }
343}
344
345/// Commands that take `window`/`doc` as their own parameters rather than as a target.
346pub fn is_window_command(id: &str) -> bool {
347    id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow" | "view.switchWindows")
348}