Skip to main content

fission_core/
text_control.rs

1//! Programmatic control of retained editable text sessions.
2
3use crate::{RuntimeState, TextEditingValue, TextRange, TextSelection};
4use fission_ir::{op::FlexDirection, CoreIR, LayoutOp, Op, Role, WidgetId};
5use fission_layout::LayoutSnapshot;
6use serde::{Deserialize, Serialize};
7use std::{error::Error, fmt};
8
9#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub enum TextEditingCommand {
11    Focus,
12    Unfocus,
13    SelectAll,
14    SetSelection(TextSelection),
15    SetValue(TextEditingValue),
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct TextEditingController {
20    id: WidgetId,
21}
22
23impl TextEditingController {
24    pub const fn new(id: WidgetId) -> Self {
25        Self { id }
26    }
27
28    pub const fn id(self) -> WidgetId {
29        self.id
30    }
31
32    pub fn value(self, state: &RuntimeState) -> Option<TextEditingValue> {
33        state
34            .text_edit
35            .get(self.id)
36            .map(|state| state.editing_value())
37    }
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41pub enum TextScrollCommand {
42    Caret,
43    Range(TextRange),
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub struct TextScrollController {
48    id: WidgetId,
49}
50
51impl TextScrollController {
52    pub const fn new(id: WidgetId) -> Self {
53        Self { id }
54    }
55
56    pub const fn id(self) -> WidgetId {
57        self.id
58    }
59
60    pub fn apply(
61        self,
62        state: &mut RuntimeState,
63        ir: &CoreIR,
64        layout: &LayoutSnapshot,
65        command: TextScrollCommand,
66    ) -> Result<(), TextControlError> {
67        let (scroll_id, text_id, direction) =
68            text_scroll_nodes(ir, self.id).ok_or(TextControlError::MissingTextInput(self.id))?;
69        let index = match command {
70            TextScrollCommand::Caret => state
71                .text_edit
72                .get(self.id)
73                .map(|value| value.caret)
74                .unwrap_or(0),
75            TextScrollCommand::Range(range) => range.end.utf8_offset(),
76        };
77        let paragraph = layout
78            .get_resolved_paragraph(text_id)
79            .ok_or(TextControlError::ParagraphNotResolved(self.id))?;
80        let caret = paragraph
81            .caret(index, false)
82            .ok_or(TextControlError::InvalidRange(self.id))?;
83        let viewport = layout
84            .get_node_geometry(scroll_id)
85            .ok_or(TextControlError::ParagraphNotResolved(self.id))?;
86        let current = state.scroll.get_offset(scroll_id);
87        let (leading, trailing, viewport_extent, content_extent) = match direction {
88            FlexDirection::Row => (
89                caret.position.x,
90                caret.position.x + 2.0,
91                viewport.rect.width(),
92                viewport.content_size.width,
93            ),
94            FlexDirection::Column => (
95                caret.position.y,
96                caret.position.y + caret.height.max(1.0),
97                viewport.rect.height(),
98                viewport.content_size.height,
99            ),
100        };
101        let mut offset = current;
102        if leading < current {
103            offset = leading;
104        } else if trailing > current + viewport_extent {
105            offset = trailing - viewport_extent;
106        }
107        state.scroll.set_offset(
108            scroll_id,
109            offset.clamp(0.0, (content_extent - viewport_extent).max(0.0)),
110        );
111        Ok(())
112    }
113}
114
115fn text_scroll_nodes(ir: &CoreIR, root: WidgetId) -> Option<(WidgetId, WidgetId, FlexDirection)> {
116    let mut stack = vec![root];
117    while let Some(id) = stack.pop() {
118        let node = ir.nodes.get(&id)?;
119        if let Op::Layout(LayoutOp::Scroll { direction, .. }) = &node.op {
120            let mut descendants = node.children.clone();
121            while let Some(child) = descendants.pop() {
122                let child_node = ir.nodes.get(&child)?;
123                if matches!(
124                    child_node.op,
125                    Op::Paint(fission_ir::PaintOp::DrawText { .. })
126                        | Op::Paint(fission_ir::PaintOp::DrawRichText { .. })
127                ) {
128                    return Some((id, child, *direction));
129                }
130                descendants.extend(child_node.children.iter().copied());
131            }
132        }
133        stack.extend(node.children.iter().copied());
134    }
135    None
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum TextControlError {
140    MissingTextInput(WidgetId),
141    InvalidRange(WidgetId),
142    ParagraphNotResolved(WidgetId),
143}
144
145impl fmt::Display for TextControlError {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            Self::MissingTextInput(id) => write!(formatter, "text input {id} is not in the tree"),
149            Self::InvalidRange(id) => {
150                write!(formatter, "text input {id} received an invalid range")
151            }
152            Self::ParagraphNotResolved(id) => {
153                write!(formatter, "text input {id} has no resolved paragraph")
154            }
155        }
156    }
157}
158
159impl Error for TextControlError {}
160
161#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
162pub struct TextFormController {
163    id: String,
164}
165
166impl TextFormController {
167    pub fn new(id: impl Into<String>) -> Self {
168        Self { id: id.into() }
169    }
170
171    pub fn id(&self) -> &str {
172        &self.id
173    }
174
175    pub fn validation(&self, ir: &CoreIR) -> TextFormValidation {
176        let mut fields = Vec::new();
177        let mut invalid = Vec::new();
178        for (node_id, node) in &ir.nodes {
179            let Op::Semantics(semantics) = &node.op else {
180                continue;
181            };
182            if semantics.role != Role::TextInput
183                || semantics.text_form_id.as_deref() != Some(self.id.as_str())
184            {
185                continue;
186            }
187            fields.push(*node_id);
188            if semantics.validation_state
189                == fission_ir::semantics::TextFieldValidationState::Invalid
190            {
191                invalid.push(*node_id);
192            }
193        }
194        TextFormValidation { fields, invalid }
195    }
196}
197
198#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
199pub struct TextFormValidation {
200    pub fields: Vec<WidgetId>,
201    pub invalid: Vec<WidgetId>,
202}
203
204impl TextFormValidation {
205    pub fn is_valid(&self) -> bool {
206        self.invalid.is_empty()
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use fission_ir::{CompositeStyle, CoreNode, Semantics};
214
215    fn text_input(ir: &mut CoreIR, id: WidgetId, value: &str) {
216        ir.nodes.insert(
217            id,
218            CoreNode {
219                id,
220                parent: None,
221                children: Vec::new(),
222                op: Op::Semantics(Semantics {
223                    role: Role::TextInput,
224                    value: Some(value.to_owned()),
225                    focusable: true,
226                    ..Semantics::default()
227                }),
228                composite: CompositeStyle::default(),
229                hash: 0,
230            },
231        );
232    }
233
234    #[test]
235    fn editing_controller_targets_one_stable_widget() {
236        let first = WidgetId::explicit("form.first");
237        let second = WidgetId::explicit("form.second");
238        let mut ir = CoreIR::default();
239        text_input(&mut ir, first, "alpha");
240        text_input(&mut ir, second, "beta");
241        let mut state = RuntimeState::default();
242        let controller = TextEditingController::new(first);
243        state
244            .text_edit
245            .sync_from_runtime(first, "alpha", None, None, false);
246        state.text_edit.set_caret(first, 5, Some(0));
247
248        assert_eq!(
249            controller.value(&state).unwrap().selection_range(),
250            TextRange::new("alpha", 0, 5).unwrap()
251        );
252        assert!(state.text_edit.get(second).is_none());
253    }
254
255    #[test]
256    fn form_controller_reports_only_invalid_members_of_its_form() {
257        let valid = WidgetId::explicit("account.name");
258        let invalid = WidgetId::explicit("account.email");
259        let unrelated = WidgetId::explicit("search.query");
260        let mut ir = CoreIR::default();
261        for (id, form, state) in [
262            (
263                valid,
264                "account",
265                fission_ir::semantics::TextFieldValidationState::Valid,
266            ),
267            (
268                invalid,
269                "account",
270                fission_ir::semantics::TextFieldValidationState::Invalid,
271            ),
272            (
273                unrelated,
274                "search",
275                fission_ir::semantics::TextFieldValidationState::Invalid,
276            ),
277        ] {
278            text_input(&mut ir, id, "value");
279            let Op::Semantics(semantics) = &mut ir.nodes.get_mut(&id).unwrap().op else {
280                unreachable!();
281            };
282            semantics.text_form_id = Some(form.to_owned());
283            semantics.validation_state = state;
284        }
285
286        let result = TextFormController::new("account").validation(&ir);
287        assert_eq!(result.fields.len(), 2);
288        assert_eq!(result.invalid, vec![invalid]);
289        assert!(!result.is_valid());
290    }
291}