diff options
Diffstat (limited to 'widget/src/text_editor.rs')
| -rw-r--r-- | widget/src/text_editor.rs | 708 | 
1 files changed, 708 insertions, 0 deletions
| diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs new file mode 100644 index 00000000..1708a2e5 --- /dev/null +++ b/widget/src/text_editor.rs @@ -0,0 +1,708 @@ +//! Display a multi-line text input for text editing. +use crate::core::event::{self, Event}; +use crate::core::keyboard; +use crate::core::layout::{self, Layout}; +use crate::core::mouse; +use crate::core::renderer; +use crate::core::text::editor::{Cursor, Editor as _}; +use crate::core::text::highlighter::{self, Highlighter}; +use crate::core::text::{self, LineHeight}; +use crate::core::widget::{self, Widget}; +use crate::core::{ +    Clipboard, Color, Element, Length, Padding, Pixels, Rectangle, Shell, +    Vector, +}; + +use std::cell::RefCell; +use std::fmt; +use std::ops::DerefMut; +use std::sync::Arc; + +pub use crate::style::text_editor::{Appearance, StyleSheet}; +pub use text::editor::{Action, Edit, Motion}; + +/// A multi-line text input. +#[allow(missing_debug_implementations)] +pub struct TextEditor<'a, Highlighter, Message, Renderer = crate::Renderer> +where +    Highlighter: text::Highlighter, +    Renderer: text::Renderer, +    Renderer::Theme: StyleSheet, +{ +    content: &'a Content<Renderer>, +    font: Option<Renderer::Font>, +    text_size: Option<Pixels>, +    line_height: LineHeight, +    width: Length, +    height: Length, +    padding: Padding, +    style: <Renderer::Theme as StyleSheet>::Style, +    on_edit: Option<Box<dyn Fn(Action) -> Message + 'a>>, +    highlighter_settings: Highlighter::Settings, +    highlighter_format: fn( +        &Highlighter::Highlight, +        &Renderer::Theme, +    ) -> highlighter::Format<Renderer::Font>, +} + +impl<'a, Message, Renderer> +    TextEditor<'a, highlighter::PlainText, Message, Renderer> +where +    Renderer: text::Renderer, +    Renderer::Theme: StyleSheet, +{ +    /// Creates new [`TextEditor`] with the given [`Content`]. +    pub fn new(content: &'a Content<Renderer>) -> Self { +        Self { +            content, +            font: None, +            text_size: None, +            line_height: LineHeight::default(), +            width: Length::Fill, +            height: Length::Fill, +            padding: Padding::new(5.0), +            style: Default::default(), +            on_edit: None, +            highlighter_settings: (), +            highlighter_format: |_highlight, _theme| { +                highlighter::Format::default() +            }, +        } +    } +} + +impl<'a, Highlighter, Message, Renderer> +    TextEditor<'a, Highlighter, Message, Renderer> +where +    Highlighter: text::Highlighter, +    Renderer: text::Renderer, +    Renderer::Theme: StyleSheet, +{ +    /// Sets the message that should be produced when some action is performed in +    /// the [`TextEditor`]. +    /// +    /// If this method is not called, the [`TextEditor`] will be disabled. +    pub fn on_action( +        mut self, +        on_edit: impl Fn(Action) -> Message + 'a, +    ) -> Self { +        self.on_edit = Some(Box::new(on_edit)); +        self +    } + +    /// Sets the [`Font`] of the [`TextEditor`]. +    /// +    /// [`Font`]: text::Renderer::Font +    pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self { +        self.font = Some(font.into()); +        self +    } + +    /// Sets the [`Padding`] of the [`TextEditor`]. +    pub fn padding(mut self, padding: impl Into<Padding>) -> Self { +        self.padding = padding.into(); +        self +    } + +    /// Highlights the [`TextEditor`] with the given [`Highlighter`] and +    /// a strategy to turn its highlights into some text format. +    pub fn highlight<H: text::Highlighter>( +        self, +        settings: H::Settings, +        to_format: fn( +            &H::Highlight, +            &Renderer::Theme, +        ) -> highlighter::Format<Renderer::Font>, +    ) -> TextEditor<'a, H, Message, Renderer> { +        TextEditor { +            content: self.content, +            font: self.font, +            text_size: self.text_size, +            line_height: self.line_height, +            width: self.width, +            height: self.height, +            padding: self.padding, +            style: self.style, +            on_edit: self.on_edit, +            highlighter_settings: settings, +            highlighter_format: to_format, +        } +    } +} + +/// The content of a [`TextEditor`]. +pub struct Content<R = crate::Renderer>(RefCell<Internal<R>>) +where +    R: text::Renderer; + +struct Internal<R> +where +    R: text::Renderer, +{ +    editor: R::Editor, +    is_dirty: bool, +} + +impl<R> Content<R> +where +    R: text::Renderer, +{ +    /// Creates an empty [`Content`]. +    pub fn new() -> Self { +        Self::with_text("") +    } + +    /// Creates a [`Content`] with the given text. +    pub fn with_text(text: &str) -> Self { +        Self(RefCell::new(Internal { +            editor: R::Editor::with_text(text), +            is_dirty: true, +        })) +    } + +    /// Performs an [`Action`] on the [`Content`]. +    pub fn perform(&mut self, action: Action) { +        let internal = self.0.get_mut(); + +        internal.editor.perform(action); +        internal.is_dirty = true; +    } + +    /// Returns the amount of lines of the [`Content`]. +    pub fn line_count(&self) -> usize { +        self.0.borrow().editor.line_count() +    } + +    /// Returns the text of the line at the given index, if it exists. +    pub fn line( +        &self, +        index: usize, +    ) -> Option<impl std::ops::Deref<Target = str> + '_> { +        std::cell::Ref::filter_map(self.0.borrow(), |internal| { +            internal.editor.line(index) +        }) +        .ok() +    } + +    /// Returns an iterator of the text of the lines in the [`Content`]. +    pub fn lines( +        &self, +    ) -> impl Iterator<Item = impl std::ops::Deref<Target = str> + '_> { +        struct Lines<'a, Renderer: text::Renderer> { +            internal: std::cell::Ref<'a, Internal<Renderer>>, +            current: usize, +        } + +        impl<'a, Renderer: text::Renderer> Iterator for Lines<'a, Renderer> { +            type Item = std::cell::Ref<'a, str>; + +            fn next(&mut self) -> Option<Self::Item> { +                let line = std::cell::Ref::filter_map( +                    std::cell::Ref::clone(&self.internal), +                    |internal| internal.editor.line(self.current), +                ) +                .ok()?; + +                self.current += 1; + +                Some(line) +            } +        } + +        Lines { +            internal: self.0.borrow(), +            current: 0, +        } +    } + +    /// Returns the text of the [`Content`]. +    /// +    /// Lines are joined with `'\n'`. +    pub fn text(&self) -> String { +        let mut text = self.lines().enumerate().fold( +            String::new(), +            |mut contents, (i, line)| { +                if i > 0 { +                    contents.push('\n'); +                } + +                contents.push_str(&line); + +                contents +            }, +        ); + +        if !text.ends_with('\n') { +            text.push('\n'); +        } + +        text +    } + +    /// Returns the selected text of the [`Content`]. +    pub fn selection(&self) -> Option<String> { +        self.0.borrow().editor.selection() +    } + +    /// Returns the current cursor position of the [`Content`]. +    pub fn cursor_position(&self) -> (usize, usize) { +        self.0.borrow().editor.cursor_position() +    } +} + +impl<Renderer> Default for Content<Renderer> +where +    Renderer: text::Renderer, +{ +    fn default() -> Self { +        Self::new() +    } +} + +impl<Renderer> fmt::Debug for Content<Renderer> +where +    Renderer: text::Renderer, +    Renderer::Editor: fmt::Debug, +{ +    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { +        let internal = self.0.borrow(); + +        f.debug_struct("Content") +            .field("editor", &internal.editor) +            .field("is_dirty", &internal.is_dirty) +            .finish() +    } +} + +struct State<Highlighter: text::Highlighter> { +    is_focused: bool, +    last_click: Option<mouse::Click>, +    drag_click: Option<mouse::click::Kind>, +    highlighter: RefCell<Highlighter>, +    highlighter_settings: Highlighter::Settings, +    highlighter_format_address: usize, +} + +impl<'a, Highlighter, Message, Renderer> Widget<Message, Renderer> +    for TextEditor<'a, Highlighter, Message, Renderer> +where +    Highlighter: text::Highlighter, +    Renderer: text::Renderer, +    Renderer::Theme: StyleSheet, +{ +    fn tag(&self) -> widget::tree::Tag { +        widget::tree::Tag::of::<State<Highlighter>>() +    } + +    fn state(&self) -> widget::tree::State { +        widget::tree::State::new(State { +            is_focused: false, +            last_click: None, +            drag_click: None, +            highlighter: RefCell::new(Highlighter::new( +                &self.highlighter_settings, +            )), +            highlighter_settings: self.highlighter_settings.clone(), +            highlighter_format_address: self.highlighter_format as usize, +        }) +    } + +    fn width(&self) -> Length { +        self.width +    } + +    fn height(&self) -> Length { +        self.height +    } + +    fn layout( +        &self, +        tree: &mut widget::Tree, +        renderer: &Renderer, +        limits: &layout::Limits, +    ) -> iced_renderer::core::layout::Node { +        let mut internal = self.content.0.borrow_mut(); +        let state = tree.state.downcast_mut::<State<Highlighter>>(); + +        if state.highlighter_format_address != self.highlighter_format as usize +        { +            state.highlighter.borrow_mut().change_line(0); + +            state.highlighter_format_address = self.highlighter_format as usize; +        } + +        if state.highlighter_settings != self.highlighter_settings { +            state +                .highlighter +                .borrow_mut() +                .update(&self.highlighter_settings); + +            state.highlighter_settings = self.highlighter_settings.clone(); +        } + +        internal.editor.update( +            limits.pad(self.padding).max(), +            self.font.unwrap_or_else(|| renderer.default_font()), +            self.text_size.unwrap_or_else(|| renderer.default_size()), +            self.line_height, +            state.highlighter.borrow_mut().deref_mut(), +        ); + +        layout::Node::new(limits.max()) +    } + +    fn on_event( +        &mut self, +        tree: &mut widget::Tree, +        event: Event, +        layout: Layout<'_>, +        cursor: mouse::Cursor, +        _renderer: &Renderer, +        clipboard: &mut dyn Clipboard, +        shell: &mut Shell<'_, Message>, +        _viewport: &Rectangle, +    ) -> event::Status { +        let Some(on_edit) = self.on_edit.as_ref() else { +            return event::Status::Ignored; +        }; + +        let state = tree.state.downcast_mut::<State<Highlighter>>(); + +        let Some(update) = Update::from_event( +            event, +            state, +            layout.bounds(), +            self.padding, +            cursor, +        ) else { +            return event::Status::Ignored; +        }; + +        match update { +            Update::Click(click) => { +                let action = match click.kind() { +                    mouse::click::Kind::Single => { +                        Action::Click(click.position()) +                    } +                    mouse::click::Kind::Double => Action::SelectWord, +                    mouse::click::Kind::Triple => Action::SelectLine, +                }; + +                state.is_focused = true; +                state.last_click = Some(click); +                state.drag_click = Some(click.kind()); + +                shell.publish(on_edit(action)); +            } +            Update::Unfocus => { +                state.is_focused = false; +                state.drag_click = None; +            } +            Update::Release => { +                state.drag_click = None; +            } +            Update::Action(action) => { +                shell.publish(on_edit(action)); +            } +            Update::Copy => { +                if let Some(selection) = self.content.selection() { +                    clipboard.write(selection); +                } +            } +            Update::Paste => { +                if let Some(contents) = clipboard.read() { +                    shell.publish(on_edit(Action::Edit(Edit::Paste( +                        Arc::new(contents), +                    )))); +                } +            } +        } + +        event::Status::Captured +    } + +    fn draw( +        &self, +        tree: &widget::Tree, +        renderer: &mut Renderer, +        theme: &<Renderer as renderer::Renderer>::Theme, +        style: &renderer::Style, +        layout: Layout<'_>, +        cursor: mouse::Cursor, +        _viewport: &Rectangle, +    ) { +        let bounds = layout.bounds(); + +        let mut internal = self.content.0.borrow_mut(); +        let state = tree.state.downcast_ref::<State<Highlighter>>(); + +        internal.editor.highlight( +            self.font.unwrap_or_else(|| renderer.default_font()), +            state.highlighter.borrow_mut().deref_mut(), +            |highlight| (self.highlighter_format)(highlight, theme), +        ); + +        let is_disabled = self.on_edit.is_none(); +        let is_mouse_over = cursor.is_over(bounds); + +        let appearance = if is_disabled { +            theme.disabled(&self.style) +        } else if state.is_focused { +            theme.focused(&self.style) +        } else if is_mouse_over { +            theme.hovered(&self.style) +        } else { +            theme.active(&self.style) +        }; + +        renderer.fill_quad( +            renderer::Quad { +                bounds, +                border_radius: appearance.border_radius, +                border_width: appearance.border_width, +                border_color: appearance.border_color, +            }, +            appearance.background, +        ); + +        renderer.fill_editor( +            &internal.editor, +            bounds.position() +                + Vector::new(self.padding.left, self.padding.top), +            style.text_color, +        ); + +        let translation = Vector::new( +            bounds.x + self.padding.left, +            bounds.y + self.padding.top, +        ); + +        if state.is_focused { +            match internal.editor.cursor() { +                Cursor::Caret(position) => { +                    let position = position + translation; + +                    if bounds.contains(position) { +                        renderer.fill_quad( +                            renderer::Quad { +                                bounds: Rectangle { +                                    x: position.x, +                                    y: position.y, +                                    width: 1.0, +                                    height: self +                                        .line_height +                                        .to_absolute( +                                            self.text_size.unwrap_or_else( +                                                || renderer.default_size(), +                                            ), +                                        ) +                                        .into(), +                                }, +                                border_radius: 0.0.into(), +                                border_width: 0.0, +                                border_color: Color::TRANSPARENT, +                            }, +                            theme.value_color(&self.style), +                        ); +                    } +                } +                Cursor::Selection(ranges) => { +                    for range in ranges.into_iter().filter_map(|range| { +                        bounds.intersection(&(range + translation)) +                    }) { +                        renderer.fill_quad( +                            renderer::Quad { +                                bounds: range, +                                border_radius: 0.0.into(), +                                border_width: 0.0, +                                border_color: Color::TRANSPARENT, +                            }, +                            theme.selection_color(&self.style), +                        ); +                    } +                } +            } +        } +    } + +    fn mouse_interaction( +        &self, +        _state: &widget::Tree, +        layout: Layout<'_>, +        cursor: mouse::Cursor, +        _viewport: &Rectangle, +        _renderer: &Renderer, +    ) -> mouse::Interaction { +        let is_disabled = self.on_edit.is_none(); + +        if cursor.is_over(layout.bounds()) { +            if is_disabled { +                mouse::Interaction::NotAllowed +            } else { +                mouse::Interaction::Text +            } +        } else { +            mouse::Interaction::default() +        } +    } +} + +impl<'a, Highlighter, Message, Renderer> +    From<TextEditor<'a, Highlighter, Message, Renderer>> +    for Element<'a, Message, Renderer> +where +    Highlighter: text::Highlighter, +    Message: 'a, +    Renderer: text::Renderer, +    Renderer::Theme: StyleSheet, +{ +    fn from( +        text_editor: TextEditor<'a, Highlighter, Message, Renderer>, +    ) -> Self { +        Self::new(text_editor) +    } +} + +enum Update { +    Click(mouse::Click), +    Unfocus, +    Release, +    Action(Action), +    Copy, +    Paste, +} + +impl Update { +    fn from_event<H: Highlighter>( +        event: Event, +        state: &State<H>, +        bounds: Rectangle, +        padding: Padding, +        cursor: mouse::Cursor, +    ) -> Option<Self> { +        let action = |action| Some(Update::Action(action)); +        let edit = |edit| action(Action::Edit(edit)); + +        match event { +            Event::Mouse(event) => match event { +                mouse::Event::ButtonPressed(mouse::Button::Left) => { +                    if let Some(cursor_position) = cursor.position_in(bounds) { +                        let cursor_position = cursor_position +                            - Vector::new(padding.top, padding.left); + +                        let click = mouse::Click::new( +                            cursor_position, +                            state.last_click, +                        ); + +                        Some(Update::Click(click)) +                    } else if state.is_focused { +                        Some(Update::Unfocus) +                    } else { +                        None +                    } +                } +                mouse::Event::ButtonReleased(mouse::Button::Left) => { +                    Some(Update::Release) +                } +                mouse::Event::CursorMoved { .. } => match state.drag_click { +                    Some(mouse::click::Kind::Single) => { +                        let cursor_position = cursor.position_in(bounds)? +                            - Vector::new(padding.top, padding.left); + +                        action(Action::Drag(cursor_position)) +                    } +                    _ => None, +                }, +                mouse::Event::WheelScrolled { delta } +                    if cursor.is_over(bounds) => +                { +                    action(Action::Scroll { +                        lines: match delta { +                            mouse::ScrollDelta::Lines { y, .. } => { +                                if y.abs() > 0.0 { +                                    (y.signum() * -(y.abs() * 4.0).max(1.0)) +                                        as i32 +                                } else { +                                    0 +                                } +                            } +                            mouse::ScrollDelta::Pixels { y, .. } => { +                                (-y / 4.0) as i32 +                            } +                        }, +                    }) +                } +                _ => None, +            }, +            Event::Keyboard(event) => match event { +                keyboard::Event::KeyPressed { +                    key_code, +                    modifiers, +                } if state.is_focused => { +                    if let Some(motion) = motion(key_code) { +                        let motion = +                            if platform::is_jump_modifier_pressed(modifiers) { +                                motion.widen() +                            } else { +                                motion +                            }; + +                        return action(if modifiers.shift() { +                            Action::Select(motion) +                        } else { +                            Action::Move(motion) +                        }); +                    } + +                    match key_code { +                        keyboard::KeyCode::Enter => edit(Edit::Enter), +                        keyboard::KeyCode::Backspace => edit(Edit::Backspace), +                        keyboard::KeyCode::Delete => edit(Edit::Delete), +                        keyboard::KeyCode::Escape => Some(Self::Unfocus), +                        keyboard::KeyCode::C if modifiers.command() => { +                            Some(Self::Copy) +                        } +                        keyboard::KeyCode::V +                            if modifiers.command() && !modifiers.alt() => +                        { +                            Some(Self::Paste) +                        } +                        _ => None, +                    } +                } +                keyboard::Event::CharacterReceived(c) if state.is_focused => { +                    edit(Edit::Insert(c)) +                } +                _ => None, +            }, +            _ => None, +        } +    } +} + +fn motion(key_code: keyboard::KeyCode) -> Option<Motion> { +    match key_code { +        keyboard::KeyCode::Left => Some(Motion::Left), +        keyboard::KeyCode::Right => Some(Motion::Right), +        keyboard::KeyCode::Up => Some(Motion::Up), +        keyboard::KeyCode::Down => Some(Motion::Down), +        keyboard::KeyCode::Home => Some(Motion::Home), +        keyboard::KeyCode::End => Some(Motion::End), +        keyboard::KeyCode::PageUp => Some(Motion::PageUp), +        keyboard::KeyCode::PageDown => Some(Motion::PageDown), +        _ => None, +    } +} + +mod platform { +    use crate::core::keyboard; + +    pub fn is_jump_modifier_pressed(modifiers: keyboard::Modifiers) -> bool { +        if cfg!(target_os = "macos") { +            modifiers.alt() +        } else { +            modifiers.control() +        } +    } +} | 
