1use 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}