Skip to main content

fret_ui_kit/headless/
text_assist.rs

1//! UI-kit glue for input-owned text-assist surfaces.
2//!
3//! This module preserves the existing headless controller/match API from
4//! `fret_ui_headless::text_assist` while adding the small amount of UI glue that should live
5//! above pure query/filter/navigation math:
6//! - input-owned expanded/collapsed policy,
7//! - active-descendant / controls-element semantics wiring,
8//! - outer keydown arbitration for Arrow/Home/Page/Enter/Escape.
9
10use 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}