Skip to main content

native/guppy_nif/src/bridge_view/roving.rs

use super::{BridgeView, events, render_pass::RenderPass};
use gpui::{
    App, Context, FocusHandle, InteractiveElement, KeyDownEvent, MouseButton,
    StatefulInteractiveElement, Window,
};
use std::collections::HashMap;

/// Focus targets for roving keyboard navigation. A `None` slot lets the key
/// fall through to the surrounding handler.
#[derive(Default, Clone)]
pub(crate) struct NavigationTargets {
    pub up: Option<FocusHandle>,
    pub down: Option<FocusHandle>,
    pub left: Option<FocusHandle>,
    pub right: Option<FocusHandle>,
    pub home: Option<FocusHandle>,
    pub end: Option<FocusHandle>,
}

impl NavigationTargets {
    pub fn target(&self, key: &str) -> Option<&FocusHandle> {
        match key {
            "up" => self.up.as_ref(),
            "down" => self.down.as_ref(),
            "left" => self.left.as_ref(),
            "right" => self.right.as_ref(),
            "home" => self.home.as_ref(),
            "end" => self.end.as_ref(),
            _ => None,
        }
    }

    /// Focuses the matched target; returns true when the event was handled.
    pub fn handle(&self, event: &KeyDownEvent, window: &mut Window, cx: &mut App) -> bool {
        let Some(handle) = self.target(event.keystroke.key.as_str()) else {
            return false;
        };

        handle.focus(window);
        cx.stop_propagation();
        true
    }
}

/// Up/down/home/end targets around `index` in an ordered item list.
pub(crate) fn vertical_neighbors<T>(
    items: &[T],
    index: usize,
    handles: &HashMap<String, FocusHandle>,
    id_of: impl Fn(&T) -> &str,
) -> NavigationTargets {
    let handle_for = |item: Option<&T>| item.and_then(|item| handles.get(id_of(item))).cloned();

    NavigationTargets {
        up: handle_for(
            index
                .checked_sub(1)
                .and_then(|previous| items.get(previous)),
        ),
        down: handle_for(items.get(index + 1)),
        home: handle_for(items.first()),
        end: handle_for(items.last()),
        ..Default::default()
    }
}

/// Enter/space activation for rows, cells, and headers; held key repeat must
/// not spam discrete activation events.
pub(crate) fn is_activation_key(event: &KeyDownEvent) -> bool {
    !event.is_held && matches!(event.keystroke.key.as_str(), "enter" | "space")
}

/// Roving focus handles for keyboard-actionable rows, keyed by item id.
/// `entries` yields `(item_id, retained_identity_key)` pairs.
pub(crate) fn prepare_focus_handles(
    pass: &mut RenderPass<'_>,
    cx: &mut Context<BridgeView>,
    keyboard_enabled: bool,
    entries: impl IntoIterator<Item = (String, String)>,
) -> HashMap<String, FocusHandle> {
    if !keyboard_enabled {
        return HashMap::new();
    }

    entries
        .into_iter()
        .map(|(item_id, identity_key)| {
            let focus_handle = pass.ensure_focus_handle(&identity_key, cx, Some(true), None);
            (item_id, focus_handle)
        })
        .collect()
}

/// The shared list-row interaction surface: roving navigation, keyboard
/// context menu, enter/space activation, click, and right-click context menu.
pub(crate) struct RowInteractionSpec<'a> {
    pub view_id: u64,
    pub row_key: &'a str,
    pub click: Option<&'a str>,
    pub context_menu: Option<&'a str>,
    pub targets: NavigationTargets,
}

pub(crate) fn attach_row_interactions<E>(mut row: E, spec: RowInteractionSpec<'_>) -> E
where
    E: InteractiveElement + StatefulInteractiveElement,
{
    let view_id = spec.view_id;

    if spec.click.is_some() || spec.context_menu.is_some() {
        let click_callback = spec.click.map(str::to_owned);
        let context_menu_callback = spec.context_menu.map(str::to_owned);
        let key_row_key = spec.row_key.to_owned();
        let targets = spec.targets;
        row = row.on_key_down(move |event: &KeyDownEvent, window, cx| {
            if targets.handle(event, window, cx) {
                return;
            }

            if let Some(callback_id) = context_menu_callback.as_deref()
                && events::is_context_menu_key(event)
            {
                events::emit_keyboard_context_menu(view_id, &key_row_key, callback_id, event);
                cx.stop_propagation();
                return;
            }

            if let Some(callback_id) = click_callback.as_deref()
                && is_activation_key(event)
            {
                events::emit_click(view_id, &key_row_key, callback_id);
                cx.stop_propagation();
            }
        });
    }

    if let Some(callback_id) = spec.click {
        let callback_id = callback_id.to_owned();
        let click_row_key = spec.row_key.to_owned();
        row = row.on_click(move |_, _, _| {
            events::emit_click(view_id, &click_row_key, &callback_id);
        });
    }

    if let Some(callback_id) = spec.context_menu {
        let callback_id = callback_id.to_owned();
        let context_row_key = spec.row_key.to_owned();
        row = row.on_mouse_down(MouseButton::Right, move |event, _, _| {
            events::emit_context_menu(view_id, &context_row_key, &callback_id, event);
        });
    }

    row
}

#[cfg(test)]
mod tests {
    use super::{NavigationTargets, is_activation_key, vertical_neighbors};
    use gpui::{KeyDownEvent, Keystroke};
    use std::collections::HashMap;

    fn key_event(key: &str, is_held: bool) -> KeyDownEvent {
        KeyDownEvent {
            keystroke: Keystroke::parse(key).unwrap(),
            is_held,
        }
    }

    #[test]
    fn activation_keys_match_enter_and_space_and_ignore_held_repeat() {
        assert!(is_activation_key(&key_event("enter", false)));
        assert!(is_activation_key(&key_event("space", false)));
        assert!(!is_activation_key(&key_event("enter", true)));
        assert!(!is_activation_key(&key_event("space", true)));
        assert!(!is_activation_key(&key_event("tab", false)));
    }

    #[test]
    fn empty_targets_match_no_keys() {
        let targets = NavigationTargets::default();

        for key in ["up", "down", "left", "right", "home", "end", "enter"] {
            assert!(targets.target(key).is_none(), "unexpected target for {key}");
        }
    }

    #[gpui::test]
    fn vertical_neighbors_map_list_positions(cx: &mut gpui::TestAppContext) {
        let items = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
        let handles: HashMap<String, gpui::FocusHandle> = cx.update(|cx| {
            items
                .iter()
                .map(|id| (id.clone(), cx.focus_handle()))
                .collect()
        });

        let middle = vertical_neighbors(&items, 1, &handles, |id| id.as_str());
        assert_eq!(middle.up.as_ref(), handles.get("a"));
        assert_eq!(middle.down.as_ref(), handles.get("c"));
        assert_eq!(middle.home.as_ref(), handles.get("a"));
        assert_eq!(middle.end.as_ref(), handles.get("c"));

        let first = vertical_neighbors(&items, 0, &handles, |id| id.as_str());
        assert!(first.up.is_none());
        assert_eq!(first.down.as_ref(), handles.get("b"));

        let last = vertical_neighbors(&items, 2, &handles, |id| id.as_str());
        assert!(last.down.is_none());
        assert_eq!(last.up.as_ref(), handles.get("b"));
    }
}