1use std::sync::Arc;
11
12use fret_core::{KeyCode, NodeId};
13use fret_runtime::Model;
14use fret_ui::action::{ActionCx, OnKeyDown, UiFocusActionHost};
15use fret_ui::elements::GlobalElementId;
16use fret_ui::{ElementContext, UiHost};
17
18use crate::declarative::active_descendant::{
19 active_descendant_for_index, active_element_for_index,
20};
21
22pub use fret_ui_headless::text_assist::*;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct InputOwnedTextAssistSemantics {
26 pub active_descendant: Option<NodeId>,
27 pub active_descendant_element: Option<u64>,
28 pub controls_element: Option<u64>,
29 pub expanded: bool,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct InputOwnedTextAssistKeyOptions {
34 pub match_mode: TextAssistMatchMode,
35 pub wrap_navigation: bool,
36 pub page_step: usize,
37}
38
39impl Default for InputOwnedTextAssistKeyOptions {
40 fn default() -> Self {
41 Self {
42 match_mode: TextAssistMatchMode::Prefix,
43 wrap_navigation: false,
44 page_step: 4,
45 }
46 }
47}
48
49pub type OnTextAssistAccept =
50 Arc<dyn Fn(&mut dyn UiFocusActionHost, ActionCx, TextAssistMatch) + 'static>;
51
52pub fn input_owned_text_assist_expanded(
53 query: &str,
54 dismissed_query: &str,
55 visible_count: usize,
56) -> bool {
57 !query.trim().is_empty() && query != dismissed_query && visible_count > 0
58}
59
60pub fn controller_with_active_item_id(
61 items: &[TextAssistItem],
62 query: &str,
63 active_item_id: Option<&Arc<str>>,
64 mode: TextAssistMatchMode,
65 wrap_navigation: bool,
66) -> TextAssistController {
67 let mut controller = TextAssistController::new(mode).with_wrap_navigation(wrap_navigation);
68 controller.rebuild(items, query);
69 if let Some(active_item_id) = active_item_id {
70 controller.set_active_item_id(Some(active_item_id.clone()));
71 }
72 controller
73}
74
75pub fn active_match_index(controller: &TextAssistController) -> Option<usize> {
76 let active = controller.active_item_id()?;
77 controller
78 .visible()
79 .iter()
80 .position(|entry| &entry.item_id == active)
81}
82
83pub fn input_owned_text_assist_semantics<H: UiHost>(
84 cx: &mut ElementContext<'_, H>,
85 option_elements: &[GlobalElementId],
86 active_index: Option<usize>,
87 controls_element: Option<GlobalElementId>,
88 expanded: bool,
89) -> InputOwnedTextAssistSemantics {
90 InputOwnedTextAssistSemantics {
91 active_descendant: expanded
92 .then(|| active_descendant_for_index(cx, option_elements, active_index))
93 .flatten(),
94 active_descendant_element: expanded
95 .then(|| active_element_for_index(option_elements, active_index))
96 .flatten()
97 .map(|element| element.0),
98 controls_element: controls_element.map(|element| element.0),
99 expanded,
100 }
101}
102
103pub fn input_owned_text_assist_key_handler(
104 items: Arc<[TextAssistItem]>,
105 query_model: Model<String>,
106 dismissed_query_model: Model<String>,
107 active_item_id_model: Model<Option<Arc<str>>>,
108 options: InputOwnedTextAssistKeyOptions,
109 on_accept: OnTextAssistAccept,
110) -> OnKeyDown {
111 Arc::new(move |host, action_cx, down| {
112 if down.repeat || down.ime_composing {
113 return false;
114 }
115
116 let query = host
117 .models_mut()
118 .read(&query_model, Clone::clone)
119 .ok()
120 .unwrap_or_default();
121 let dismissed_query = host
122 .models_mut()
123 .read(&dismissed_query_model, Clone::clone)
124 .ok()
125 .unwrap_or_default();
126 let active_item_id = host
127 .models_mut()
128 .read(&active_item_id_model, Clone::clone)
129 .ok()
130 .unwrap_or(None);
131
132 let mut controller = controller_with_active_item_id(
133 items.as_ref(),
134 &query,
135 active_item_id.as_ref(),
136 options.match_mode,
137 options.wrap_navigation,
138 );
139 let visible_count = if query.trim().is_empty() {
140 0
141 } else {
142 controller.visible().len()
143 };
144 let expanded = input_owned_text_assist_expanded(&query, &dismissed_query, visible_count);
145
146 let movement = match down.key {
147 KeyCode::ArrowDown => Some(TextAssistMove::Next),
148 KeyCode::ArrowUp => Some(TextAssistMove::Previous),
149 KeyCode::PageDown => Some(TextAssistMove::PageDown {
150 amount: options.page_step.max(1),
151 }),
152 KeyCode::PageUp => Some(TextAssistMove::PageUp {
153 amount: options.page_step.max(1),
154 }),
155 KeyCode::Home => Some(TextAssistMove::First),
156 KeyCode::End => Some(TextAssistMove::Last),
157 _ => None,
158 };
159
160 if let Some(movement) = movement {
161 if query.trim().is_empty() || visible_count == 0 {
162 return false;
163 }
164
165 if query == dismissed_query {
166 let _ = host.models_mut().update(&dismissed_query_model, |value| {
167 value.clear();
168 });
169 }
170
171 controller.move_active(movement);
172 let next_active = controller.active_item_id().cloned();
173 let _ = host
174 .models_mut()
175 .update(&active_item_id_model, |value| *value = next_active);
176 host.request_redraw(action_cx.window);
177 return true;
178 }
179
180 match down.key {
181 KeyCode::Enter | KeyCode::NumpadEnter => {
182 if !expanded {
183 return false;
184 }
185 let Some(active) = controller.active_match().cloned() else {
186 return false;
187 };
188 on_accept(host, action_cx, active);
189 true
190 }
191 KeyCode::Escape => {
192 if !expanded {
193 return false;
194 }
195 let _ = host.models_mut().update(&dismissed_query_model, |value| {
196 value.clear();
197 value.push_str(&query);
198 });
199 host.request_redraw(action_cx.window);
200 true
201 }
202 _ => false,
203 }
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use std::sync::Arc;
210
211 use super::{
212 TextAssistItem, TextAssistMatchMode, active_match_index, controller_with_active_item_id,
213 input_owned_text_assist_expanded,
214 };
215
216 fn sample_items() -> Vec<TextAssistItem> {
217 vec![
218 TextAssistItem::new("cube", "Cube"),
219 TextAssistItem::new("cylinder", "Cylinder"),
220 TextAssistItem::new("capsule", "Capsule"),
221 ]
222 }
223
224 #[test]
225 fn expanded_requires_non_empty_visible_and_not_dismissed_query() {
226 assert!(!input_owned_text_assist_expanded("", "", 3));
227 assert!(!input_owned_text_assist_expanded("c", "c", 3));
228 assert!(!input_owned_text_assist_expanded("c", "", 0));
229 assert!(input_owned_text_assist_expanded("c", "", 3));
230 }
231
232 #[test]
233 fn controller_helper_restores_requested_active_item() {
234 let items = sample_items();
235 let active = Arc::<str>::from("capsule");
236 let controller = controller_with_active_item_id(
237 &items,
238 "c",
239 Some(&active),
240 TextAssistMatchMode::Prefix,
241 false,
242 );
243
244 assert_eq!(
245 controller.active_item_id().map(|id| id.as_ref()),
246 Some("capsule")
247 );
248 }
249
250 #[test]
251 fn active_match_index_tracks_the_resolved_active_row() {
252 let items = sample_items();
253 let active = Arc::<str>::from("cylinder");
254 let controller = controller_with_active_item_id(
255 &items,
256 "c",
257 Some(&active),
258 TextAssistMatchMode::Prefix,
259 false,
260 );
261
262 assert_eq!(active_match_index(&controller), Some(1));
263 }
264}