kael_ui 0.2.0

Professional shadcn-inspired UI component library for Kael. 100+ accessible components for building beautiful, performant desktop applications.
//! Text field component - Simple text input field.

use crate::theme::use_theme;
use kael::{prelude::FluentBuilder as _, *};
use std::ops::Range;

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum TextFieldSize {
    Sm,
    Md,
    Lg,
}

pub struct TextFieldState {
    focus_handle: FocusHandle,
    text: String,
    cursor_position: usize,
    marked_range: Option<Range<usize>>,
}

impl TextFieldState {
    pub fn new(cx: &mut Context<Self>) -> Self {
        Self {
            focus_handle: cx.focus_handle(),
            text: String::new(),
            cursor_position: 0,
            marked_range: None,
        }
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    pub fn set_text(&mut self, text: String, cx: &mut Context<Self>) {
        self.text = text;
        self.cursor_position = self.text.len();
        cx.notify();
    }

    fn insert_text(&mut self, text: &str, cx: &mut Context<Self>) {
        self.text.insert_str(self.cursor_position, text);
        self.cursor_position += text.len();
        cx.notify();
    }
}

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

impl EntityInputHandler for TextFieldState {
    fn text_for_range(
        &mut self,
        range_utf16: Range<usize>,
        actual_range: &mut Option<Range<usize>>,
        _window: &mut Window,
        _cx: &mut Context<Self>,
    ) -> Option<String> {
        actual_range.replace(range_utf16.clone());
        let start = range_utf16.start.min(self.text.len());
        let end = range_utf16.end.min(self.text.len());
        Some(self.text[start..end].to_string())
    }

    fn selected_text_range(
        &mut self,
        _ignore_disabled_input: bool,
        _window: &mut Window,
        _cx: &mut Context<Self>,
    ) -> Option<UTF16Selection> {
        Some(UTF16Selection {
            range: self.cursor_position..self.cursor_position,
            reversed: false,
        })
    }

    fn marked_text_range(
        &self,
        _window: &mut Window,
        _cx: &mut Context<Self>,
    ) -> Option<Range<usize>> {
        self.marked_range.clone()
    }

    fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
        self.marked_range = None;
    }

    fn replace_text_in_range(
        &mut self,
        range_utf16: Option<Range<usize>>,
        new_text: &str,
        _window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        if let Some(range) = range_utf16 {
            let start = range.start.min(self.text.len());
            let end = range.end.min(self.text.len());
            self.text.replace_range(start..end, new_text);
            self.cursor_position = start + new_text.len();
        } else {
            self.insert_text(new_text, cx);
        }
        cx.notify();
    }

    fn replace_and_mark_text_in_range(
        &mut self,
        range_utf16: Option<Range<usize>>,
        new_text: &str,
        new_selected_range_utf16: Option<Range<usize>>,
        _window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        if let Some(range) = range_utf16 {
            let start = range.start.min(self.text.len());
            let end = range.end.min(self.text.len());
            self.text.replace_range(start..end, new_text);
            self.cursor_position = start + new_text.len();

            if !new_text.is_empty() {
                self.marked_range = Some(start..start + new_text.len());
            }
        }

        if let Some(sel_range) = new_selected_range_utf16 {
            self.cursor_position = sel_range.end.min(self.text.len());
        }

        cx.notify();
    }

    fn bounds_for_range(
        &mut self,
        _range_utf16: Range<usize>,
        _bounds: Bounds<Pixels>,
        _window: &mut Window,
        _cx: &mut Context<Self>,
    ) -> Option<Bounds<Pixels>> {
        None
    }

    fn character_index_for_point(
        &mut self,
        _point: Point<Pixels>,
        _window: &mut Window,
        _cx: &mut Context<Self>,
    ) -> Option<usize> {
        None
    }
}

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

#[derive(IntoElement)]
pub struct TextField {
    state: Entity<TextFieldState>,
    size: TextFieldSize,
    placeholder: Option<SharedString>,
    disabled: bool,
    invalid: bool,
    style: StyleRefinement,
}

impl TextField {
    pub fn new(cx: &mut App) -> Self {
        let state = cx.new(TextFieldState::new);
        Self {
            state,
            size: TextFieldSize::Md,
            placeholder: None,
            disabled: false,
            invalid: false,
            style: StyleRefinement::default(),
        }
    }

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

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

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

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

    pub fn value<T: Into<String>>(self, value: T, cx: &mut App) -> Self {
        self.state.update(cx, |state, cx| {
            state.set_text(value.into(), cx);
        });
        self
    }

    pub fn text(&self, cx: &App) -> String {
        self.state.read(cx).text().to_string()
    }

    pub fn on_change<F: Fn(&str, &mut Window, &mut App) + 'static>(self, _f: F) -> Self {
        self
    }
}

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

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

        let (height, padding_x, padding_y, text_size) = match self.size {
            TextFieldSize::Sm => (px(36.0), px(12.0), px(8.0), px(14.0)),
            TextFieldSize::Md => (px(40.0), px(12.0), px(8.0), px(14.0)),
            TextFieldSize::Lg => (px(44.0), px(12.0), px(10.0), px(16.0)),
        };

        let border_color = if self.invalid {
            theme.tokens.destructive
        } else {
            theme.tokens.input
        };

        let focus_handle = self.state.read(cx).focus_handle.clone();
        let focus_handle_for_mouse = focus_handle.clone();
        let text_content = self.state.read(cx).text().to_string();

        let mut base = div()
            .id(("text-field", self.state.entity_id()))
            .track_focus(&focus_handle)
            .h(height)
            .px(padding_x)
            .py(padding_y)
            .bg(theme.tokens.background)
            .border_1()
            .border_color(border_color)
            .rounded(theme.tokens.radius_md);

        if self.disabled {
            base = base.opacity(0.5);
        }

        base.map(|this| {
            let mut div = this;
            div.style().refine(&user_style);
            div
        })
        .child(
            div().size_full().flex().items_center().child(
                canvas_with_prepaint(
                    move |bounds, window, cx| {
                        let focus_handle = focus_handle.clone();
                        window.handle_input(
                            &focus_handle,
                            ElementInputHandler::new(bounds, self.state.clone()),
                            cx,
                        );
                    },
                    move |bounds, _data, window, cx| {
                        if !text_content.is_empty() {
                            let text_style = window.text_style();
                            let text_run = TextRun {
                                len: text_content.len(),
                                font: text_style.font(),
                                color: theme.tokens.foreground,
                                background_color: None,
                                underline: None,
                                strikethrough: None,
                            };

                            let shaped = window.text_system().shape_line(
                                text_content.clone().into(),
                                text_size,
                                &[text_run],
                                None,
                            );

                            let _ = shaped.paint(
                                point(bounds.left(), bounds.top()),
                                height,
                                window,
                                cx,
                            );
                        } else if let Some(placeholder) = &self.placeholder {
                            let text_style = window.text_style();
                            let text_run = TextRun {
                                len: placeholder.len(),
                                font: text_style.font(),
                                color: theme.tokens.muted_foreground,
                                background_color: None,
                                underline: None,
                                strikethrough: None,
                            };

                            let shaped = window.text_system().shape_line(
                                placeholder.clone(),
                                text_size,
                                &[text_run],
                                None,
                            );

                            let _ = shaped.paint(
                                point(bounds.left(), bounds.top()),
                                height,
                                window,
                                cx,
                            );
                        }
                    },
                )
                .size_full(),
            ),
        )
        .on_mouse_down(MouseButton::Left, move |_event, window, _cx| {
            window.focus(&focus_handle_for_mouse);
        })
    }
}