liora-components 0.1.11

Enterprise-style native GPUI component library for Liora applications.
//! Tree module.
//!
//! This public module implements the Liora tree view component for hierarchical data. It keeps the reusable
//! component logic inside `liora-components` rather than Gallery or Docs so
//! downstream GPUI applications can compose the same behavior with their own
//! app state, assets, and release policy.
//!
//! ## Usage model
//!
//! Components in this module render native GPUI element trees. Stateless builder
//! values can be constructed inline, while controls with focus, selection,
//! popup, drag, or editing state should be stored as `gpui::Entity<T>` fields in
//! the parent view so state survives GPUI render passes.
//!
//! ## Design contract
//!
//! The implementation should use Liora theme tokens from `liora-core` and
//! `liora-theme`, keep accessibility-oriented keyboard/pointer behavior close to
//! the component, and avoid app-specific Gallery/Docs resources in this SDK
//! crate.

use crate::gpui_compat::element_id;
use crate::motion::pop_in;
use gpui::{
    AnyElement, App, Context, IntoElement, Pixels, Render, SharedString, Window, div, prelude::*,
    px,
};
use liora_core::Config;
use liora_icons::Icon;
use liora_icons_lucide::IconName;
use std::collections::HashSet;

#[derive(Debug, Clone, PartialEq, Eq)]
/// Fluent native GPUI component for rendering Liora tree node.
pub struct TreeNode {
    /// Stable identifier used for GPUI state, callbacks, and automation.
    pub id: SharedString,
    /// User-facing label rendered for this item.
    pub label: SharedString,
    /// Nested child items rendered beneath this item.
    pub children: Vec<TreeNode>,
}

/// Fluent native GPUI component for rendering Liora tree.
pub struct Tree {
    data: Vec<TreeNode>,
    expanded_keys: HashSet<SharedString>,
    selected_keys: HashSet<SharedString>,
    multiple: bool,
    indent: Pixels,
    show_checkbox: bool,
    on_node_click: Option<Box<dyn Fn(SharedString, &mut Window, &mut App) + 'static>>,
}

impl TreeNode {
    /// Creates `TreeNode` initialized from the supplied id, and label.
    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
        Self {
            id: id.into(),
            label: label.into(),
            children: vec![],
        }
    }

    /// Adds a child element to the component body.
    pub fn child(mut self, child: TreeNode) -> Self {
        self.children.push(child);
        self
    }
}

impl Tree {
    /// Creates `Tree` that renders the supplied data collection.
    pub fn new(data: Vec<TreeNode>) -> Self {
        Self {
            data,
            expanded_keys: HashSet::new(),
            selected_keys: HashSet::new(),
            multiple: false,
            indent: px(18.0),
            show_checkbox: false,
            on_node_click: None,
        }
    }

    /// Sets the layout indent.
    pub fn indent(mut self, indent: impl Into<Pixels>) -> Self {
        self.indent = indent.into();
        self
    }

    /// Configures whether checkbox is visible in the rendered component.
    pub fn show_checkbox(mut self, show: bool) -> Self {
        self.show_checkbox = show;
        self
    }

    /// Enables multi-selection behavior.
    pub fn multiple(mut self, multiple: bool) -> Self {
        self.multiple = multiple;
        self
    }

    /// Registers a callback that runs when node click occurs.
    pub fn on_node_click(
        mut self,
        f: impl Fn(SharedString, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.on_node_click = Some(Box::new(f));
        self
    }

    fn toggle_expand(&mut self, id: SharedString, cx: &mut Context<Self>) {
        if self.expanded_keys.contains(&id) {
            self.expanded_keys.remove(&id);
        } else {
            self.expanded_keys.insert(id);
        }
        cx.notify();
    }

    fn select_node(&mut self, id: SharedString, window: &mut Window, cx: &mut Context<Self>) {
        if self.multiple {
            if self.selected_keys.contains(&id) {
                self.selected_keys.remove(&id);
            } else {
                self.selected_keys.insert(id.clone());
            }
        } else {
            self.selected_keys.clear();
            self.selected_keys.insert(id.clone());
        }

        if let Some(ref on_click) = self.on_node_click {
            (on_click)(id, window, cx);
        }
        cx.notify();
    }

    fn click_node(
        &mut self,
        id: SharedString,
        has_children: bool,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        if has_children {
            if self.expanded_keys.contains(&id) {
                self.expanded_keys.remove(&id);
            } else {
                self.expanded_keys.insert(id.clone());
            }
        }
        self.select_node(id, window, cx);
    }

    fn render_node(
        &self,
        node: &TreeNode,
        depth: u32,
        theme: &liora_theme::Theme,
        cx: &Context<Self>,
    ) -> AnyElement {
        let id = node.id.clone();
        let is_expanded = self.expanded_keys.contains(&id);
        let is_selected = self.selected_keys.contains(&id);
        let has_children = !node.children.is_empty();
        let padding_left = px(f32::from(self.indent) * depth as f32);

        div()
            .flex()
            .flex_col()
            .child(
                div()
                    .id(id.clone())
                    .cursor_pointer()
                    .flex()
                    .flex_row()
                    .items_center()
                    .gap_1()
                    .h(px(32.0))
                    .pl(padding_left)
                    .pr_4()
                    .text_color(if is_selected {
                        theme.primary.base
                    } else {
                        theme.neutral.text_1
                    })
                    .bg(if is_selected {
                        theme.primary.base.opacity(0.1)
                    } else {
                        gpui::transparent_black()
                    })
                    .hover(|s| s.bg(theme.neutral.hover))
                    .child(
                        // Expand Icon
                        div()
                            .flex()
                            .items_center()
                            .justify_center()
                            .w(px(20.0))
                            .id(element_id(format!("expand-{}", id.clone())))
                            .when(has_children, |s| {
                                s.on_click(cx.listener({
                                    let id = id.clone();
                                    move |this, _, _, cx| {
                                        this.toggle_expand(id.clone(), cx);
                                        cx.stop_propagation();
                                    }
                                }))
                                .child(
                                    Icon::new(if is_expanded {
                                        IconName::ChevronDown
                                    } else {
                                        IconName::ChevronRight
                                    })
                                    .size(px(14.0))
                                    .color(theme.neutral.text_3),
                                )
                            }),
                    )
                    .on_click(cx.listener({
                        let id = id.clone();
                        move |this, _, window, cx| {
                            this.click_node(id.clone(), has_children, window, cx);
                        }
                    }))
                    .child(
                        div()
                            .flex_1()
                            .id(element_id(format!("content-{}", id.clone())))
                            .child(div().text_sm().child(node.label.clone())),
                    ),
            )
            .when(is_expanded && has_children, |s| {
                s.child(pop_in(
                    element_id(format!("tree-children-motion-{}", id)),
                    div().flex().flex_col().children(
                        node.children
                            .iter()
                            .map(|child| self.render_node(child, depth + 1, theme, cx)),
                    ),
                ))
            })
            .into_any_element()
    }
}

impl Render for Tree {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = cx.global::<Config>().theme.clone();

        div().flex().flex_col().w_full().children(
            self.data
                .iter()
                .map(|node| self.render_node(node, 0, &theme, cx)),
        )
    }
}