Skip to main content

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}