Skip to main content

tilo_app/framework/
text.rs

1//! Text controls over collaborative plain text: every edit a splice action.
2
3use tilo_resource::text::splice_between;
4use tilo_resource::{Doc, Model, Path, PlainText, Resource, Segment, TextEdit};
5use ui_core::automerge::{ObjId, ReadDoc as _};
6use ui_core::widget::{
7    AppModel, Autocomplete, CodeEditor, Decorator, EventCtx, ModelPath, TextInput, Widget,
8};
9use ui_core::winit::event::WindowEvent;
10
11use super::dispatch::Dispatcher;
12
13/// A text control a [`TextControl`] drives: it shows the text object it is
14/// bound to and keeps the user's edits local, for its owner to submit.
15pub trait EditsText: Widget {
16    /// Show the text object `obj`, reading it on every sync, and keep edits
17    /// out of the document.
18    fn show_text(&mut self, obj: ObjId);
19    /// Stop showing a text object.
20    fn hide_text(&mut self);
21    /// The text after the user's edits since the last call, if any.
22    fn take_edited(&mut self) -> Option<String>;
23    /// Show `text`, which is not a user edit.
24    fn reset_text(&mut self, text: &str);
25    fn shown_text(&self) -> &str;
26}
27
28impl EditsText for TextInput {
29    fn show_text(&mut self, obj: ObjId) {
30        self.bind_text(obj);
31    }
32    fn hide_text(&mut self) {
33        self.clear_text_binding();
34    }
35    fn take_edited(&mut self) -> Option<String> {
36        self.take_dirty()
37    }
38    fn reset_text(&mut self, text: &str) {
39        self.set_text(text);
40    }
41    fn shown_text(&self) -> &str {
42        self.value()
43    }
44}
45
46impl EditsText for CodeEditor {
47    fn show_text(&mut self, obj: ObjId) {
48        self.bind_text(obj);
49    }
50    fn hide_text(&mut self) {
51        self.clear_text_binding();
52    }
53    fn take_edited(&mut self) -> Option<String> {
54        self.take_dirty()
55    }
56    fn reset_text(&mut self, text: &str) {
57        self.set_text(text);
58    }
59    fn shown_text(&self) -> &str {
60        self.value()
61    }
62}
63
64impl EditsText for Autocomplete {
65    fn show_text(&mut self, obj: ObjId) {
66        self.input_mut().show_text(obj);
67    }
68    fn hide_text(&mut self) {
69        self.input_mut().hide_text();
70    }
71    fn take_edited(&mut self) -> Option<String> {
72        Autocomplete::take_edited(self)
73    }
74    fn reset_text(&mut self, text: &str) {
75        self.set_text(text);
76    }
77    fn shown_text(&self) -> &str {
78        self.text()
79    }
80}
81
82/// Finds the text object a control edits.
83type Locate = Box<dyn Fn(&Doc) -> Option<ObjId>>;
84
85/// Submits one splice of the text a control edits.
86type Splice<R> = Box<
87    dyn Fn(
88        &Dispatcher<R>,
89        &mut ui_core::widget::Edits<'_>,
90        TextEdit,
91    ) -> Result<(), tilo_actions::ActionError>,
92>;
93
94/// A text field or code editor over collaborative text — a [`PlainText`]
95/// resource or child (a SQL buffer, a report note), or a text field of a
96/// record at its [`Path`] ([`Self::at`]) — whose every edit is a splice
97/// through `dispatcher`.
98///
99/// So typing is permission-checked (a viewer's keystrokes change nothing and
100/// the control shows the stored text again), attributed, undone a word at a
101/// time, and merges with a collaborator typing in the same text character
102/// by character. The control reads the text object `locate` finds on every
103/// sync; a collaborator's edit, an undo or a restore shows as it lands.
104/// Without a dispatcher ([`view_only`](Self::view_only)) it shows the text
105/// and every edit is taken back.
106pub struct TextControl<W: EditsText, R: Resource = PlainText> {
107    control: W,
108    dispatch: Option<Dispatcher<R>>,
109    locate: Locate,
110    splice: Splice<R>,
111    /// The text object shown.
112    bound: Option<ObjId>,
113    /// The text as the document holds it, which the next edit is a splice of.
114    stored: String,
115}
116
117impl<W: EditsText> TextControl<W> {
118    /// `control` over the text `locate` finds, edited through `dispatcher`.
119    pub fn new(
120        control: W,
121        dispatcher: Dispatcher<PlainText>,
122        locate: impl Fn(&Doc) -> Option<ObjId> + 'static,
123    ) -> Self {
124        let mut text = Self::view_only(control, locate);
125        text.attach(dispatcher);
126        text
127    }
128
129    /// `control` showing the text `locate` finds, never editing it.
130    pub fn view_only(control: W, locate: impl Fn(&Doc) -> Option<ObjId> + 'static) -> Self {
131        Self {
132            control,
133            dispatch: None,
134            locate: Box::new(locate),
135            splice: Box::new(|dispatch, edits, edit| dispatch.apply_now(edits, &edit)),
136            bound: None,
137            stored: String::new(),
138        }
139    }
140}
141
142impl<W: EditsText, R: Resource<State = R> + Model> TextControl<W, R> {
143    /// `control` over the text field of the record at `path`, each edit a
144    /// field edit there (`path.splice(..)`) through `dispatcher`: for a
145    /// hand-written surface's own editor.
146    pub fn at(control: W, dispatcher: Dispatcher<R>, path: Path<R, String>) -> Self {
147        let mut text = Self::view_only_at(control, path);
148        text.attach(dispatcher);
149        text
150    }
151
152    /// `control` showing the text field at `path`, every edit taken back
153    /// until [`attach`](Self::attach) hands it a dispatcher: for a surface
154    /// that builds its editors before the framework attaches it.
155    pub fn view_only_at(control: W, path: Path<R, String>) -> Self {
156        let segments = path.segments().to_vec();
157        Self {
158            control,
159            dispatch: None,
160            locate: Box::new(move |doc| text_at(doc, &segments)),
161            splice: Box::new(move |dispatch, edits, edit| {
162                let edit = match edit {
163                    TextEdit::Splice { at, delete, insert } => path.splice(at, delete, insert),
164                    TextEdit::ReplaceText { text } => path.replace(text),
165                };
166                dispatch.submit_now(edits, edit)
167            }),
168            bound: None,
169            stored: String::new(),
170        }
171    }
172}
173
174/// The text object at `path` in `doc`: fields and map entries by key, items
175/// of a collection through their entry's value.
176fn text_at(doc: &Doc, path: &[Segment]) -> Option<ObjId> {
177    let mut obj = ui_core::automerge::ROOT;
178    for segment in path {
179        let child = |obj: &ObjId, key: &str| match doc.get(obj, key) {
180            Ok(Some((ui_core::automerge::Value::Object(_), id))) => Some(id),
181            _ => None,
182        };
183        obj = match segment {
184            Segment::Field(key) | Segment::Key(key) => child(&obj, key)?,
185            Segment::Item(id) => {
186                let entry = child(&obj, id.as_str())?;
187                child(&entry, tilo_resource::shape::ENTRY_VALUE_KEY)?
188            }
189            Segment::Cell { .. } => return None,
190        };
191    }
192    Some(obj)
193}
194
195impl<W: EditsText, R: Resource> TextControl<W, R> {
196    /// Edit through `dispatcher` from now on.
197    pub fn attach(&mut self, dispatcher: Dispatcher<R>) {
198        self.dispatch = Some(dispatcher);
199    }
200
201    pub fn control(&self) -> &W {
202        &self.control
203    }
204
205    /// The control, to configure it (a caret, a selection, a highlighter).
206    pub fn control_mut(&mut self) -> &mut W {
207        &mut self.control
208    }
209
210    /// Bind the text `locate` finds now in `doc`: for a host outside the
211    /// widget tree, before it reads the control each frame.
212    pub fn pull(&mut self, doc: &Doc) {
213        self.follow(doc);
214        self.control.sync(doc, &ui_core::automerge::ROOT);
215        self.stored = self.control.shown_text().to_owned();
216    }
217
218    /// Submit the control's edit since the last call as one splice: for a
219    /// host outside the widget tree, after it handed the control an event.
220    pub fn submit(&mut self, edits: &mut ui_core::widget::Edits<'_>) {
221        let Some(edited) = self.control.take_edited() else {
222            return;
223        };
224        let Some(edit) = splice_between(&self.stored, &edited) else {
225            return;
226        };
227        let applied = match &self.dispatch {
228            Some(dispatch) => (self.splice)(dispatch, edits, edit),
229            None => Err(tilo_actions::ActionError::Refused(
230                "this text is open to view only".into(),
231            )),
232        };
233        match applied {
234            Ok(()) => self.stored = edited,
235            Err(error) => {
236                tracing::warn!(%error, "text edit refused");
237                let stored = self.stored.clone();
238                self.control.reset_text(&stored);
239            }
240        }
241    }
242
243    fn follow(&mut self, doc: &Doc) {
244        let located = (self.locate)(doc);
245        if located == self.bound {
246            return;
247        }
248        if let Some(obj) = &located {
249            self.control.show_text(obj.clone());
250        } else {
251            self.control.hide_text();
252            self.control.reset_text("");
253        }
254        self.bound = located;
255    }
256}
257
258// The control's layout, drawing and input are its own; the wrapper binds it
259// to the text before each sync and turns its edits into splices after.
260impl<W: EditsText + 'static, R: Resource> Decorator for TextControl<W, R> {
261    type Inner = W;
262
263    fn inner(&self) -> &W {
264        &self.control
265    }
266
267    fn inner_mut(&mut self) -> &mut W {
268        &mut self.control
269    }
270
271    fn sync(&mut self, state: &AppModel, path: &ModelPath) {
272        {
273            let (doc, _) = ui_core::doc::document_ref(state, path);
274            self.follow(doc);
275        }
276        self.control.sync(state, path);
277        self.stored = self.control.shown_text().to_owned();
278    }
279
280    fn handle_event(&mut self, event: &WindowEvent, ctx: &mut EventCtx<'_>) {
281        self.control.handle_event(event, ctx);
282        self.submit(&mut ctx.edits());
283    }
284
285    fn perform_action(
286        &mut self,
287        action: &ui_core::a11y::Action,
288        key_path: &[u64],
289        state: &mut ui_core::widget::Edits<'_>,
290        path: &ModelPath,
291    ) -> bool {
292        let handled = self.control.perform_action(action, key_path, state, path);
293        self.submit(state);
294        handled
295    }
296
297    fn perform_semantic_action(
298        &mut self,
299        target: &str,
300        action: &ui_core::a11y::Action,
301        state: &mut ui_core::widget::Edits<'_>,
302        path: &ModelPath,
303    ) -> bool {
304        let handled = self
305            .control
306            .perform_semantic_action(target, action, state, path);
307        self.submit(state);
308        handled
309    }
310}