Skip to main content

native/guppy_nif/src/bridge_view/render_choice.rs

use super::{
    BridgeView,
    identity::NodeIdentity,
    render_pass::RenderPass,
    style::{apply_div_style, apply_refinement_style},
};
use crate::ir::DivStyle;
use gpui::{
    AnyElement, Context, InteractiveElement, IntoElement, KeyDownEvent, ParentElement,
    StatefulInteractiveElement, Styled, Window, div, rgb,
};

// Default choice-control palette, shared by checkbox and radio. The wrapper
// receives the default text color before user style ops run, so `text_color`
// ops on the node theme the label (and checkbox glyph background contrast
// stays an indicator-local concern).
pub(crate) const CHOICE_BORDER: u32 = 0x94a3b8;
pub(crate) const CHOICE_BORDER_DISABLED: u32 = 0x5b6472;
pub(crate) const CHOICE_FILL_CHECKED: u32 = 0x2563eb;
pub(crate) const CHOICE_FILL_CHECKED_DISABLED: u32 = 0x475569;
pub(crate) const CHOICE_FILL_UNCHECKED: u32 = 0x0f172a;
pub(crate) const CHOICE_GLYPH: u32 = 0xffffff;
pub(crate) const CHOICE_GLYPH_DISABLED: u32 = 0xcbd5e1;
pub(crate) const CHOICE_TEXT: u32 = 0xe2e8f0;
pub(crate) const CHOICE_TEXT_DISABLED: u32 = 0x94a3b8;

pub(crate) fn indicator_border_color(disabled: bool) -> u32 {
    if disabled {
        CHOICE_BORDER_DISABLED
    } else {
        CHOICE_BORDER
    }
}

pub(crate) fn indicator_fill_color(checked: bool, disabled: bool) -> u32 {
    match (checked, disabled) {
        (true, true) => CHOICE_FILL_CHECKED_DISABLED,
        (true, false) => CHOICE_FILL_CHECKED,
        (false, _) => CHOICE_FILL_UNCHECKED,
    }
}

pub(crate) fn glyph_color(disabled: bool) -> u32 {
    if disabled {
        CHOICE_GLYPH_DISABLED
    } else {
        CHOICE_GLYPH
    }
}

pub(crate) fn default_text_color(disabled: bool) -> u32 {
    if disabled {
        CHOICE_TEXT_DISABLED
    } else {
        CHOICE_TEXT
    }
}

/// Label element that inherits the wrapper text color, so `text_color` style
/// ops on the node theme it from Elixir.
pub(crate) fn choice_label(label: &str) -> AnyElement {
    div().child(label.to_owned()).into_any_element()
}

/// Shared shape of the checkbox/radio choice controls: everything except the
/// indicator element, the label element, and the change event payload.
pub(crate) struct ChoiceSpec<'a> {
    pub id: Option<&'a str>,
    pub disabled: bool,
    pub tab_index: Option<isize>,
    pub focus: Option<&'a str>,
    pub blur: Option<&'a str>,
    pub change: Option<&'a str>,
    pub style: &'a DivStyle,
    pub hover_style: &'a DivStyle,
    pub focus_style: &'a DivStyle,
    pub focus_visible_style: &'a DivStyle,
    pub in_focus_style: &'a DivStyle,
    pub active_style: &'a DivStyle,
    pub disabled_style: &'a DivStyle,
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn render<F>(
    pass: &mut RenderPass<'_>,
    path: &str,
    spec: ChoiceSpec<'_>,
    indicator: AnyElement,
    label: AnyElement,
    emit_change: F,
    window: &mut Window,
    cx: &mut Context<BridgeView>,
) -> AnyElement
where
    F: Fn(u64, &str, &str) + Clone + 'static,
{
    let view_id = pass.view_id();
    let node_id = NodeIdentity::new(view_id, path, spec.id);
    let node_key = node_id.to_string();

    let focus_handle = if spec.disabled {
        None
    } else {
        Some(pass.ensure_focus_handle(&node_key, cx, Some(true), spec.tab_index))
    };

    if let Some(handle) = focus_handle.as_ref() {
        pass.register_focus_callbacks(&node_key, handle, spec.focus, spec.blur, window, cx);
    }

    let mut control = apply_div_style(
        div()
            .id(node_id.to_shared_string())
            .flex()
            .flex_row()
            .items_center()
            .gap_2()
            .text_color(rgb(default_text_color(spec.disabled)))
            .child(indicator)
            .child(label),
        spec.style,
    );

    if let Some(handle) = focus_handle.as_ref() {
        let handle = handle.clone();
        control =
            control
                .track_focus(&handle)
                .focusable()
                .on_any_mouse_down(move |_, window, _| {
                    handle.focus(window);
                });
    }

    if !spec.disabled && !spec.hover_style.is_empty() {
        let hover_ops = spec.hover_style.clone();
        control = control.hover(move |style| apply_refinement_style(style, &hover_ops));
    }

    if !spec.disabled
        && !spec.focus_visible_style.is_empty()
        && pass.focus_visible()
        && focus_handle
            .as_ref()
            .is_some_and(|handle| handle.is_focused(window))
    {
        control = apply_div_style(control, spec.focus_visible_style);
    }

    if !spec.disabled && !spec.focus_style.is_empty() {
        let focus_ops = spec.focus_style.clone();
        control = control.focus(move |style| apply_refinement_style(style, &focus_ops));
    }

    if !spec.disabled && !spec.in_focus_style.is_empty() {
        let in_focus_ops = spec.in_focus_style.clone();
        control = control.in_focus(move |style| apply_refinement_style(style, &in_focus_ops));
    }

    if !spec.disabled && !spec.active_style.is_empty() {
        let active_ops = spec.active_style.clone();
        control = control.active(move |style| apply_refinement_style(style, &active_ops));
    }

    if spec.disabled && !spec.disabled_style.is_empty() {
        control = apply_div_style(control, spec.disabled_style);
    }

    if let Some(callback_id) = enabled_change_callback(spec.disabled, spec.change) {
        let click_emit = emit_change.clone();
        let click_callback_id = callback_id.to_owned();
        let click_node_id = node_key.clone();

        control = control.on_click(move |_, _, _| {
            click_emit(view_id, &click_node_id, &click_callback_id);
        });

        let key_emit = emit_change;
        let key_callback_id = callback_id.to_owned();
        let key_node_id = node_key.clone();
        control = control.on_key_down(move |event: &KeyDownEvent, _, cx| {
            if choice_toggle_triggered(event) {
                key_emit(view_id, &key_node_id, &key_callback_id);
                cx.stop_propagation();
            }
        });
    }

    control.into_any_element()
}

pub(crate) fn enabled_change_callback(disabled: bool, callback: Option<&str>) -> Option<&str> {
    if disabled { None } else { callback }
}

pub(crate) fn is_choice_toggle_key(event: &KeyDownEvent) -> bool {
    matches!(event.keystroke.key.as_str(), "space" | "enter")
}

/// Held key repeat must not spam discrete change events.
pub(crate) fn choice_toggle_triggered(event: &KeyDownEvent) -> bool {
    !event.is_held && is_choice_toggle_key(event)
}

#[cfg(test)]
mod tests {
    use super::{
        CHOICE_BORDER, CHOICE_BORDER_DISABLED, CHOICE_FILL_CHECKED, CHOICE_FILL_CHECKED_DISABLED,
        CHOICE_FILL_UNCHECKED, CHOICE_TEXT, CHOICE_TEXT_DISABLED, default_text_color,
        enabled_change_callback, indicator_border_color, indicator_fill_color,
        is_choice_toggle_key,
    };
    use gpui::{KeyDownEvent, Keystroke};

    #[test]
    fn palette_helpers_map_disabled_states() {
        assert_eq!(indicator_border_color(false), CHOICE_BORDER);
        assert_eq!(indicator_border_color(true), CHOICE_BORDER_DISABLED);
        assert_eq!(indicator_fill_color(true, false), CHOICE_FILL_CHECKED);
        assert_eq!(
            indicator_fill_color(true, true),
            CHOICE_FILL_CHECKED_DISABLED
        );
        assert_eq!(indicator_fill_color(false, false), CHOICE_FILL_UNCHECKED);
        assert_eq!(indicator_fill_color(false, true), CHOICE_FILL_UNCHECKED);
        assert_eq!(default_text_color(false), CHOICE_TEXT);
        assert_eq!(default_text_color(true), CHOICE_TEXT_DISABLED);
    }

    #[test]
    fn disabled_choice_controls_do_not_attach_change_handlers() {
        assert_eq!(
            enabled_change_callback(false, Some("toggle")),
            Some("toggle")
        );
        assert_eq!(enabled_change_callback(true, Some("toggle")), None);
        assert_eq!(enabled_change_callback(false, None), None);
    }

    #[test]
    fn held_keys_do_not_toggle_choice_controls() {
        assert!(super::choice_toggle_triggered(&KeyDownEvent {
            keystroke: Keystroke::parse("space").unwrap(),
            is_held: false,
        }));
        assert!(!super::choice_toggle_triggered(&KeyDownEvent {
            keystroke: Keystroke::parse("space").unwrap(),
            is_held: true,
        }));
        assert!(!super::choice_toggle_triggered(&KeyDownEvent {
            keystroke: Keystroke::parse("enter").unwrap(),
            is_held: true,
        }));
    }

    #[test]
    fn choice_toggle_keys_match_space_and_enter() {
        assert!(is_choice_toggle_key(&KeyDownEvent {
            keystroke: Keystroke::parse("space").unwrap(),
            is_held: false,
        }));
        assert!(is_choice_toggle_key(&KeyDownEvent {
            keystroke: Keystroke::parse("enter").unwrap(),
            is_held: false,
        }));
        assert!(!is_choice_toggle_key(&KeyDownEvent {
            keystroke: Keystroke::parse("tab").unwrap(),
            is_held: false,
        }));
    }
}