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