ccf_gpui_widgets/widgets/focus_navigation.rs
1//! Focus navigation support for Tab/Shift+Tab between widgets
2//!
3//! This module provides actions and key bindings for tab navigation.
4//! Call `register_focus_navigation_keybindings` at application startup
5//! to enable Tab/Shift+Tab navigation between widgets.
6
7use gpui::prelude::*;
8use gpui::*;
9
10// Define actions for focus navigation
11actions!(ccf_focus, [FocusNext, FocusPrev]);
12
13/// Register Tab and Shift+Tab key bindings for focus navigation.
14///
15/// Call this once at application startup:
16///
17/// ```ignore
18/// use ccf_gpui_widgets::widgets::register_focus_navigation_keybindings;
19///
20/// Application::new().run(|cx: &mut App| {
21/// register_focus_navigation_keybindings(cx);
22/// // ... rest of your initialization
23/// });
24/// ```
25pub fn register_keybindings(cx: &mut App) {
26 cx.bind_keys([
27 KeyBinding::new("tab", FocusNext, None),
28 KeyBinding::new("shift-tab", FocusPrev, None),
29 ]);
30}
31
32/// Handle tab key navigation in on_key_down handlers.
33/// Returns true if the event was handled (tab key was pressed).
34pub fn handle_tab_navigation(event: &KeyDownEvent, window: &mut Window) -> bool {
35 if event.keystroke.key == "tab" {
36 if event.keystroke.modifiers.shift {
37 window.focus_prev();
38 } else {
39 window.focus_next();
40 }
41 true
42 } else {
43 false
44 }
45}
46
47/// Apply standard focus navigation actions (Tab / Shift+Tab) to an element.
48///
49/// This helper reduces boilerplate in widget render methods by adding
50/// the common FocusNext and FocusPrev action handlers.
51///
52/// # Example
53///
54/// ```ignore
55/// use ccf_gpui_widgets::widgets::focus_navigation::with_focus_actions;
56///
57/// with_focus_actions(
58/// div()
59/// .id("my_widget")
60/// .track_focus(&focus_handle),
61/// cx,
62/// )
63/// ```
64pub fn with_focus_actions<V: 'static, E: InteractiveElement>(element: E, cx: &mut Context<V>) -> E {
65 element
66 .on_action(cx.listener(|_this, _: &FocusNext, window, _cx| {
67 window.focus_next();
68 }))
69 .on_action(cx.listener(|_this, _: &FocusPrev, window, _cx| {
70 window.focus_prev();
71 }))
72}
73
74/// Extension trait for applying cursor styling based on enabled state.
75///
76/// This reduces the common pattern of:
77/// ```ignore
78/// .when(enabled, |d| d.cursor_pointer())
79/// .when(!enabled, |d| d.cursor_default())
80/// ```
81///
82/// To simply:
83/// ```ignore
84/// .cursor_for_enabled(enabled)
85/// ```
86pub trait EnabledCursorExt: Styled + Sized + FluentBuilder {
87 /// Apply cursor styling based on enabled state.
88 /// When enabled, uses pointer cursor; otherwise uses default cursor.
89 fn cursor_for_enabled(self, enabled: bool) -> Self {
90 self.when(enabled, |d| d.cursor_pointer())
91 .when(!enabled, |d| d.cursor_default())
92 }
93}
94
95impl<E: Styled + Sized + FluentBuilder> EnabledCursorExt for E {}
96
97use crate::theme::Theme;
98use super::repeatable_text_input::ActivateButton as RepeatableActivateButton;
99
100// ============================================================================
101// CRITICAL BUG WARNING - DO NOT REMOVE action_just_handled MECHANISM
102// ============================================================================
103//
104// The repeatable button helpers below use an `action_just_handled` flag to
105// prevent DOUBLE-TRIGGERING when Space/Enter is pressed on a focused button.
106//
107// WHY THIS HAPPENS:
108// When Space/Enter is pressed on a focused button, GPUI fires BOTH:
109// 1. on_action() - from the ActivateButton keybinding
110// 2. on_click() - because keyboard activation counts as a "click"
111//
112// Without the flag, pressing Space/Enter once would add/remove TWO entries!
113//
114// HOW THE FIX WORKS:
115// - The action handler sets `action_just_handled = true` before calling the callback
116// - The click handler checks this flag and skips if true, then resets it
117// - This ensures only ONE handler actually performs the action
118//
119// THIS BUG HAS BEEN REINTRODUCED AT LEAST 3 TIMES. DO NOT:
120// - Remove the action_just_handled field from widget structs
121// - Remove the flag-setting logic from callbacks
122// - "Simplify" by removing what looks like unused state
123//
124// If you're refactoring this code, TEST by pressing Space/Enter on the +/-
125// buttons and verify only ONE entry is added/removed per keypress.
126// ============================================================================
127
128/// Render a repeatable widget remove button (minus icon).
129///
130/// Creates a 28x28 button with a horizontal minus sign, styled according to the theme.
131/// Handles focus, hover states, and disabled styling.
132///
133/// # Arguments
134/// * `id` - Unique element ID for the button
135/// * `focus_handle` - Focus handle for keyboard navigation
136/// * `theme` - Theme for colors
137/// * `enabled` - Whether the button is interactive
138/// * `is_focused` - Whether the button currently has focus
139/// * `on_action_activate` - Callback for action handler (should set action_just_handled = true)
140/// * `on_click_activate` - Callback for click handler (should check/reset action_just_handled)
141/// * `cx` - Context for creating listeners
142///
143/// # Double-Trigger Prevention
144/// The two separate callbacks allow the caller to implement the action_just_handled pattern.
145/// See the module-level comment for why this is critical.
146#[allow(clippy::too_many_arguments)] // Required for double-trigger prevention pattern
147pub fn repeatable_remove_button<V: 'static>(
148 id: impl Into<SharedString>,
149 focus_handle: &FocusHandle,
150 theme: &Theme,
151 enabled: bool,
152 is_focused: bool,
153 on_action_activate: impl Fn(&mut V, &mut Window, &mut Context<V>) + 'static,
154 on_click_activate: impl Fn(&mut V, &mut Window, &mut Context<V>) + 'static,
155 cx: &mut Context<V>,
156) -> Stateful<Div> {
157 let line_color = if enabled { theme.text_label } else { theme.disabled_text };
158
159 let mut button = with_focus_actions(
160 div()
161 .id(id.into())
162 .key_context("CcfRepeatableButton")
163 .track_focus(focus_handle)
164 .tab_stop(enabled),
165 cx,
166 )
167 .flex()
168 .items_center()
169 .justify_center()
170 .h(px(28.))
171 .w(px(28.))
172 .rounded_md()
173 .border_2()
174 .cursor_for_enabled(enabled)
175 .when(enabled, |d| {
176 d.bg(rgb(theme.delete_bg))
177 .hover(|d| d.bg(rgb(theme.delete_bg_hover)))
178 .border_color(if is_focused { rgb(theme.border_focus) } else { rgba(0x00000000) })
179 })
180 .when(!enabled, |d| {
181 d.bg(rgb(theme.disabled_bg))
182 .border_color(rgba(0x00000000))
183 })
184 // Draw minus sign with a horizontal line
185 .child(
186 div()
187 .w(px(10.))
188 .h(px(2.))
189 .rounded_sm()
190 .bg(rgb(line_color))
191 );
192
193 if enabled {
194 button = button
195 .on_action(cx.listener(move |this, _: &RepeatableActivateButton, window, cx| {
196 on_action_activate(this, window, cx);
197 }))
198 .on_click(cx.listener(move |this, _event, window, cx| {
199 on_click_activate(this, window, cx);
200 }));
201 }
202
203 button
204}
205
206/// Render a repeatable widget add button (plus icon).
207///
208/// Creates a 28x28 button with a plus sign (crossed lines), styled according to the theme.
209/// Handles focus, hover states, and disabled styling.
210///
211/// # Arguments
212/// * `id` - Unique element ID for the button
213/// * `focus_handle` - Focus handle for keyboard navigation
214/// * `theme` - Theme for colors
215/// * `enabled` - Whether the button is interactive
216/// * `is_focused` - Whether the button currently has focus
217/// * `on_action_activate` - Callback for action handler (should set action_just_handled = true)
218/// * `on_click_activate` - Callback for click handler (should check/reset action_just_handled)
219/// * `cx` - Context for creating listeners
220///
221/// # Double-Trigger Prevention
222/// The two separate callbacks allow the caller to implement the action_just_handled pattern.
223/// See the module-level comment for why this is critical.
224#[allow(clippy::too_many_arguments)] // Required for double-trigger prevention pattern
225pub fn repeatable_add_button<V: 'static>(
226 id: impl Into<SharedString>,
227 focus_handle: &FocusHandle,
228 theme: &Theme,
229 enabled: bool,
230 is_focused: bool,
231 on_action_activate: impl Fn(&mut V, &mut Window, &mut Context<V>) + 'static,
232 on_click_activate: impl Fn(&mut V, &mut Window, &mut Context<V>) + 'static,
233 cx: &mut Context<V>,
234) -> Stateful<Div> {
235 let line_color = if enabled { theme.text_label } else { theme.disabled_text };
236
237 let mut button = with_focus_actions(
238 div()
239 .id(id.into())
240 .key_context("CcfRepeatableButton")
241 .track_focus(focus_handle)
242 .tab_stop(enabled),
243 cx,
244 )
245 .flex()
246 .items_center()
247 .justify_center()
248 .h(px(28.))
249 .w(px(28.))
250 .rounded_md()
251 .border_2()
252 .cursor_for_enabled(enabled)
253 .when(enabled, |d| {
254 d.bg(rgb(theme.bg_input_hover))
255 .hover(|d| d.bg(rgb(theme.bg_hover)))
256 .border_color(if is_focused { rgb(theme.border_focus) } else { rgba(0x00000000) })
257 })
258 .when(!enabled, |d| {
259 d.bg(rgb(theme.disabled_bg))
260 .border_color(rgba(0x00000000))
261 })
262 // Draw plus sign with crossed lines
263 .child(
264 div()
265 .relative()
266 .w(px(10.))
267 .h(px(10.))
268 // Horizontal line
269 .child(
270 div()
271 .absolute()
272 .top(px(4.))
273 .left(px(0.))
274 .w(px(10.))
275 .h(px(2.))
276 .rounded_sm()
277 .bg(rgb(line_color))
278 )
279 // Vertical line
280 .child(
281 div()
282 .absolute()
283 .top(px(0.))
284 .left(px(4.))
285 .w(px(2.))
286 .h(px(10.))
287 .rounded_sm()
288 .bg(rgb(line_color))
289 )
290 );
291
292 if enabled {
293 button = button
294 .on_action(cx.listener(move |this, _: &RepeatableActivateButton, window, cx| {
295 on_action_activate(this, window, cx);
296 }))
297 .on_click(cx.listener(move |this, _event, window, cx| {
298 on_click_activate(this, window, cx);
299 }));
300 }
301
302 button
303}