kael_ui 0.2.0

Professional shadcn-inspired UI component library for Kael. 100+ accessible components for building beautiful, performant desktop applications.
use crate::theme::use_theme;
use kael::{prelude::FluentBuilder as _, *};
use std::rc::Rc;

#[derive(Clone)]
pub struct HotkeyValue {
    pub key: String,
    pub modifiers: Modifiers,
}

impl HotkeyValue {
    pub fn new(key: impl Into<String>, modifiers: Modifiers) -> Self {
        Self {
            key: key.into(),
            modifiers,
        }
    }

    pub fn from_keystroke(keystroke: &Keystroke) -> Option<Self> {
        let key = keystroke.key.as_str();
        if Self::is_modifier_only(key) {
            return None;
        }
        Some(Self {
            key: key.to_string(),
            modifiers: keystroke.modifiers,
        })
    }

    fn is_modifier_only(key: &str) -> bool {
        matches!(
            key.to_lowercase().as_str(),
            "shift" | "control" | "alt" | "meta" | "cmd" | "command" | "ctrl" | "option"
        )
    }

    #[cfg(target_os = "macos")]
    pub fn format_display(&self) -> String {
        let mut result = String::new();
        if self.modifiers.control {
            result.push('');
        }
        if self.modifiers.alt {
            result.push('');
        }
        if self.modifiers.shift {
            result.push('');
        }
        if self.modifiers.platform {
            result.push('');
        }
        result.push_str(&self.format_key());
        result
    }

    #[cfg(not(target_os = "macos"))]
    pub fn format_display(&self) -> String {
        let mut parts = Vec::new();
        if self.modifiers.control {
            parts.push("Ctrl".to_string());
        }
        if self.modifiers.alt {
            parts.push("Alt".to_string());
        }
        if self.modifiers.shift {
            parts.push("Shift".to_string());
        }
        if self.modifiers.platform {
            parts.push("Win".to_string());
        }
        parts.push(self.format_key());
        parts.join("+")
    }

    fn format_key(&self) -> String {
        match self.key.as_str() {
            "space" => "Space".to_string(),
            "enter" => "Enter".to_string(),
            "escape" => "Esc".to_string(),
            "tab" => "Tab".to_string(),
            "backspace" => "Backspace".to_string(),
            "delete" => "Del".to_string(),
            "up" => "Up".to_string(),
            "down" => "Down".to_string(),
            "left" => "Left".to_string(),
            "right" => "Right".to_string(),
            "home" => "Home".to_string(),
            "end" => "End".to_string(),
            "pageup" => "PgUp".to_string(),
            "pagedown" => "PgDn".to_string(),
            k if k.starts_with('f') && k.len() <= 3 => k.to_uppercase(),
            k => k.to_uppercase(),
        }
    }
}

pub struct HotkeyInputState {
    hotkey: Option<HotkeyValue>,
    recording: bool,
    focus_handle: FocusHandle,
}

impl HotkeyInputState {
    pub fn new(cx: &mut Context<Self>) -> Self {
        Self {
            hotkey: None,
            recording: false,
            focus_handle: cx.focus_handle(),
        }
    }

    pub fn with_hotkey(cx: &mut Context<Self>, hotkey: HotkeyValue) -> Self {
        Self {
            hotkey: Some(hotkey),
            recording: false,
            focus_handle: cx.focus_handle(),
        }
    }

    pub fn hotkey(&self) -> Option<&HotkeyValue> {
        self.hotkey.as_ref()
    }

    pub fn set_hotkey(&mut self, hotkey: Option<HotkeyValue>, cx: &mut Context<Self>) {
        self.hotkey = hotkey;
        self.recording = false;
        cx.notify();
    }

    pub fn is_recording(&self) -> bool {
        self.recording
    }

    pub fn start_recording(&mut self, cx: &mut Context<Self>) {
        self.recording = true;
        cx.notify();
    }

    pub fn stop_recording(&mut self, cx: &mut Context<Self>) {
        self.recording = false;
        cx.notify();
    }

    pub fn clear(&mut self, cx: &mut Context<Self>) {
        self.hotkey = None;
        self.recording = false;
        cx.notify();
    }

    pub fn capture_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
        if !self.recording {
            return false;
        }

        if keystroke.key.as_str() == "escape" {
            self.stop_recording(cx);
            return true;
        }

        if let Some(hotkey) = HotkeyValue::from_keystroke(keystroke) {
            self.hotkey = Some(hotkey);
            self.recording = false;
            cx.notify();
            return true;
        }

        false
    }
}

impl Focusable for HotkeyInputState {
    fn focus_handle(&self, _: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

impl Render for HotkeyInputState {
    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
        div()
    }
}

#[derive(IntoElement)]
pub struct HotkeyInput {
    state: Entity<HotkeyInputState>,
    placeholder: SharedString,
    disabled: bool,
    on_change: Option<Rc<dyn Fn(Option<&HotkeyValue>, &mut Window, &mut App)>>,
    style: StyleRefinement,
}

impl HotkeyInput {
    pub fn new(state: Entity<HotkeyInputState>) -> Self {
        Self {
            state,
            placeholder: "Click to record".into(),
            disabled: false,
            on_change: None,
            style: StyleRefinement::default(),
        }
    }

    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
        self.placeholder = placeholder.into();
        self
    }

    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    pub fn on_change(
        mut self,
        handler: impl Fn(Option<&HotkeyValue>, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.on_change = Some(Rc::new(handler));
        self
    }
}

impl Styled for HotkeyInput {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for HotkeyInput {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = use_theme();
        let user_style = self.style;

        let state_data = self.state.read(cx);
        let hotkey = state_data.hotkey.clone();
        let recording = state_data.recording;
        let focus_handle = state_data.focus_handle(cx);
        let is_focused = focus_handle.is_focused(window);

        let display_text: SharedString = if recording {
            "Press a key...".into()
        } else if let Some(ref hk) = hotkey {
            hk.format_display().into()
        } else {
            self.placeholder.clone()
        };

        let has_value = hotkey.is_some();
        let show_clear = has_value && !self.disabled && !recording;

        let state_for_click = self.state.clone();
        let state_for_keydown = self.state.clone();
        let state_for_clear = self.state.clone();

        let on_change_for_keydown = self.on_change.clone();
        let on_change_for_clear = self.on_change.clone();

        let border_color = if recording {
            theme.tokens.primary
        } else if is_focused {
            theme.tokens.ring
        } else {
            theme.tokens.input
        };

        let focus_ring = theme.tokens.focus_ring_light();
        let recording_ring = BoxShadow {
            offset: point(px(0.0), px(0.0)),
            blur_radius: px(0.0),
            spread_radius: px(3.0),
            inset: false,
            color: theme.tokens.primary.opacity(0.3),
        };

        let text_color = if hotkey.is_some() && !recording {
            theme.tokens.foreground
        } else {
            theme.tokens.muted_foreground
        };

        let clear_button = if show_clear {
            Some(
                div()
                    .id("hotkey-clear")
                    .ml(px(8.0))
                    .px(px(6.0))
                    .py(px(4.0))
                    .rounded(px(4.0))
                    .text_color(theme.tokens.muted_foreground)
                    .hover(|s| s.bg(theme.tokens.muted).text_color(theme.tokens.foreground))
                    .on_click(move |_, window, cx| {
                        state_for_clear.update(cx, |state, cx| {
                            state.clear(cx);
                        });
                        if let Some(ref handler) = on_change_for_clear {
                            handler(None, window, cx);
                        }
                        cx.stop_propagation();
                    })
                    .child("×"),
            )
        } else {
            None
        };

        div()
            .map(|this| {
                let mut d = this;
                d.style().refine(&user_style);
                d
            })
            .child(
                div()
                    .id(("hotkey-input", self.state.entity_id()))
                    .track_focus(&focus_handle.tab_index(0).tab_stop(true))
                    .h(px(40.0))
                    .px(px(12.0))
                    .flex()
                    .items_center()
                    .justify_between()
                    .bg(theme.tokens.background)
                    .border_1()
                    .border_color(border_color)
                    .rounded(theme.tokens.radius_md)
                    .font_family(theme.tokens.font_mono.clone())
                    .text_size(px(14.0))
                    .when(self.disabled, |d| d.opacity(0.5).cursor_not_allowed())
                    .when(!self.disabled, |d| d.cursor_pointer())
                    .when(is_focused && !recording, |d| {
                        d.shadow(smallvec::smallvec![focus_ring])
                    })
                    .when(recording, |d| {
                        d.shadow(smallvec::smallvec![recording_ring])
                            .border_color(theme.tokens.primary)
                    })
                    .when(!self.disabled, |d| {
                        d.on_click(move |_, window, cx| {
                            state_for_click.update(cx, |state, cx| {
                                if !state.recording {
                                    state.start_recording(cx);
                                }
                            });
                            window.refresh();
                        })
                    })
                    .when(!self.disabled, |d| {
                        d.on_key_down(move |event, window, cx| {
                            let (captured, hotkey) = state_for_keydown.update(cx, |state, cx| {
                                let captured = state.capture_keystroke(&event.keystroke, cx);
                                (captured, state.hotkey.clone())
                            });
                            if captured {
                                if let Some(ref handler) = on_change_for_keydown {
                                    handler(hotkey.as_ref(), window, cx);
                                }
                                cx.stop_propagation();
                            }
                        })
                    })
                    .child(
                        div()
                            .flex_1()
                            .text_color(text_color)
                            .when(recording, |d| d.opacity(0.7))
                            .child(display_text),
                    )
                    .children(clear_button),
            )
    }
}