use iced::advanced::clipboard::{self, Clipboard};
use iced::advanced::layout;
use iced::advanced::mouse;
use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::text;
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{Layout, Shell};
use iced::widget::text_input;
use iced::{
    Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
    Size, Vector, alignment,
};
use std::rc::Rc;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
    pub value: String,
    pub cursor: usize,
}

pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
    let value = text_input::Value::new(value);
    let (start, end) = normalized_range(&value, start, end);

    (start != end).then(|| value.select(start, end).to_string())
}

pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option<String>) {
    let mut value = text_input::Value::new(value);
    let (start, end) = normalized_range(&value, start, end);

    if start == end {
        return (
            Edit {
                value: value.to_string(),
                cursor: start,
            },
            None,
        );
    }

    let selected = value.select(start, end).to_string();
    value.remove_many(start, end);

    (
        Edit {
            value: value.to_string(),
            cursor: start,
        },
        Some(selected),
    )
}

pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
    let mut value = text_input::Value::new(value);
    let (start, end) = normalized_range(&value, start, end);
    let clip = text_input::Value::new(clip);
    let cursor = start + clip.len();

    if start != end {
        value.remove_many(start, end);
    }
    value.insert_many(start, clip);

    Edit {
        value: value.to_string(),
        cursor,
    }
}

/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY
/// selection) from clipboard text before it is pasted. Shared by the
/// right-click menu Paste and the middle-click PRIMARY paste.
pub fn sanitize_clip(raw: &str) -> String {
    raw.chars().filter(|c| !c.is_control()).collect()
}

pub fn select_all_range(value: &str) -> (usize, usize) {
    let value = text_input::Value::new(value);

    (0, value.len())
}

fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) {
    let len = value.len();

    (start.min(end).min(len), start.max(end).min(len))
}

type InputStyleFn<'a, Theme> = Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;

pub fn context_input<'a, Message, Theme, Renderer>(
    placeholder: &str,
    value: &str,
) -> ContextInput<'a, Message, Theme, Renderer>
where
    Message: Clone + 'a,
    Theme: text_input::Catalog + 'a,
    Renderer: text::Renderer,
{
    ContextInput::new(placeholder, value)
}

pub fn locked_value<'a, Message, Theme, Renderer>(
    value: &str,
    noop: Message,
) -> ContextInput<'a, Message, Theme, Renderer>
where
    Message: Clone + 'a,
    Theme: text_input::Catalog + 'a,
    Renderer: text::Renderer,
{
    ContextInput::new("", value)
        .on_input(move |_| noop.clone())
        .locked(true)
}

pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
    Theme: text_input::Catalog,
    Renderer: text::Renderer,
{
    input: text_input::TextInput<'a, Message, Theme, Renderer>,
    value: String,
    is_secure: bool,
    locked: bool,
    on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
    on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
    style: Option<InputStyleFn<'a, Theme>>,
}

impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
where
    Message: Clone + 'a,
    Theme: text_input::Catalog + 'a,
    Renderer: text::Renderer,
{
    pub fn new(placeholder: &str, value: &str) -> Self {
        Self {
            input: text_input::TextInput::new(placeholder, value),
            value: value.to_owned(),
            is_secure: false,
            locked: false,
            on_input: None,
            on_paste: None,
            style: None,
        }
    }

    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
        self.input = self.input.id(id);
        self
    }

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

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

    pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
        let on_input: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_input);
        let input_callback = Rc::clone(&on_input);

        self.input = self
            .input
            .on_input(move |value| input_callback.as_ref()(value));
        self.on_input = Some(on_input);
        self
    }

    pub fn on_submit(mut self, message: Message) -> Self {
        self.input = self.input.on_submit(message);
        self
    }

    pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
        self.input = self.input.on_submit_maybe(message);
        self
    }

    pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
        let on_paste: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_paste);
        let paste_callback = Rc::clone(&on_paste);

        self.input = self
            .input
            .on_paste(move |value| paste_callback.as_ref()(value));
        self.on_paste = Some(on_paste);
        self
    }

    pub fn font(mut self, font: Renderer::Font) -> Self {
        self.input = self.input.font(font);
        self
    }

    pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
        self.input = self.input.icon(icon);
        self
    }

    pub fn width(mut self, width: impl Into<Length>) -> Self {
        self.input = self.input.width(width);
        self
    }

    pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
        self.input = self.input.padding(padding);
        self
    }

    pub fn size(mut self, size: impl Into<Pixels>) -> Self {
        self.input = self.input.size(size);
        self
    }

    pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
        self.input = self.input.line_height(line_height);
        self
    }

    pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
        self.input = self.input.align_x(alignment);
        self
    }

    pub fn style(
        mut self,
        style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
    ) -> Self
    where
        Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
    {
        let style: InputStyleFn<'a, Theme> = Rc::new(style);
        let input_style = Rc::clone(&style);

        self.input = self
            .input
            .style(move |theme, status| input_style.as_ref()(theme, status));
        self.style = Some(style);
        self
    }

    pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
        self.input = self.input.class(class);
        self.style = None;
        self
    }
}

#[derive(Default)]
struct ContextInputState {
    menu: Option<MenuState>,
}

#[derive(Debug, Clone, Copy)]
struct MenuState {
    anchor: Point,
    selection: (usize, usize),
}

impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
    for ContextInput<'_, Message, Theme, Renderer>
where
    Message: Clone,
    Theme: text_input::Catalog,
    Renderer: text::Renderer,
{
    fn tag(&self) -> tree::Tag {
        tree::Tag::of::<ContextInputState>()
    }

    fn state(&self) -> tree::State {
        tree::State::new(ContextInputState::default())
    }

    fn children(&self) -> Vec<Tree> {
        vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
    }

    fn diff(&self, tree: &mut Tree) {
        if tree.children.is_empty() {
            tree.children
                .push(Tree::new(&self.input as &dyn Widget<_, _, _>));
        } else {
            tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
            tree.children.truncate(1);
        }
    }

    fn size(&self) -> Size<Length> {
        Widget::size(&self.input)
    }

    fn size_hint(&self) -> Size<Length> {
        Widget::size_hint(&self.input)
    }

    fn layout(
        &mut self,
        tree: &mut Tree,
        renderer: &Renderer,
        limits: &layout::Limits,
    ) -> layout::Node {
        Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
    }

    fn operate(
        &mut self,
        tree: &mut Tree,
        layout: Layout<'_>,
        renderer: &Renderer,
        operation: &mut dyn widget::Operation,
    ) {
        Widget::operate(
            &mut self.input,
            &mut tree.children[0],
            layout,
            renderer,
            operation,
        );
    }

    fn update(
        &mut self,
        tree: &mut Tree,
        event: &Event,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        renderer: &Renderer,
        clipboard: &mut dyn Clipboard,
        shell: &mut Shell<'_, Message>,
        viewport: &Rectangle,
    ) {
        let right_click_on_input = matches!(
            event,
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
        ) && cursor.is_over(layout.bounds());

        if right_click_on_input {
            let value = text_input::Value::new(&self.value);
            let input_state = tree.children[0]
                .state
                .downcast_ref::<text_input::State<Renderer::Paragraph>>();
            let selection = match input_state.cursor().state(&value) {
                text_input::cursor::State::Index(index) => {
                    let index = index.min(value.len());
                    (index, index)
                }
                text_input::cursor::State::Selection { start, end } => {
                    normalized_range(&value, start, end)
                }
            };

            tree.state.downcast_mut::<ContextInputState>().menu = cursor
                .position()
                .map(|anchor| MenuState { anchor, selection });

            shell.capture_event();
            shell.request_redraw();
            return;
        }

        // Middle-click pastes the X11 PRIMARY selection at the cursor. iced's
        // base text_input only wires Ctrl+V to the Standard (CLIPBOARD)
        // selection, so without this the common "select text, middle-click to
        // paste" workflow does nothing on X11.
        let middle_click_on_input = matches!(
            event,
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
        ) && cursor.is_over(layout.bounds());

        if middle_click_on_input && !self.locked {
            let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default());

            if !clip.is_empty() {
                let value = text_input::Value::new(&self.value);
                let input_state = tree.children[0]
                    .state
                    .downcast_mut::<text_input::State<Renderer::Paragraph>>();
                let (start, end) = match input_state.cursor().state(&value) {
                    text_input::cursor::State::Index(index) => {
                        let index = index.min(value.len());
                        (index, index)
                    }
                    text_input::cursor::State::Selection { start, end } => {
                        normalized_range(&value, start, end)
                    }
                };

                let edit = paste(&self.value, start, end, &clip);
                input_state.move_cursor_to(edit.cursor);

                if let Some(on_paste) = &self.on_paste {
                    shell.publish(on_paste.as_ref()(edit.value));
                } else if let Some(on_input) = &self.on_input {
                    shell.publish(on_input.as_ref()(edit.value));
                }
            }

            shell.capture_event();
            shell.request_redraw();
            return;
        }

        Widget::update(
            &mut self.input,
            &mut tree.children[0],
            event,
            layout,
            cursor,
            renderer,
            clipboard,
            shell,
            viewport,
        );
    }

    fn draw(
        &self,
        tree: &Tree,
        renderer: &mut Renderer,
        theme: &Theme,
        style: &renderer::Style,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        viewport: &Rectangle,
    ) {
        Widget::draw(
            &self.input,
            &tree.children[0],
            renderer,
            theme,
            style,
            layout,
            cursor,
            viewport,
        );
    }

    fn mouse_interaction(
        &self,
        tree: &Tree,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        viewport: &Rectangle,
        renderer: &Renderer,
    ) -> mouse::Interaction {
        Widget::mouse_interaction(
            &self.input,
            &tree.children[0],
            layout,
            cursor,
            viewport,
            renderer,
        )
    }

    fn overlay<'a>(
        &'a mut self,
        tree: &'a mut Tree,
        _layout: Layout<'a>,
        _renderer: &Renderer,
        _viewport: &Rectangle,
        _translation: Vector,
    ) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
        let Tree {
            state, children, ..
        } = tree;
        let menu = &mut state.downcast_mut::<ContextInputState>().menu;

        if menu.is_none() {
            return None;
        }

        let input_state = children[0]
            .state
            .downcast_mut::<text_input::State<Renderer::Paragraph>>();

        Some(overlay::Element::new(Box::new(ContextMenuOverlay {
            menu,
            input_state,
            value: &self.value,
            is_secure: self.is_secure,
            locked: self.locked,
            on_input: self.on_input.clone(),
            on_paste: self.on_paste.clone(),
            style: self.style.clone(),
        })))
    }
}

impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
    for Element<'a, Message, Theme, Renderer>
where
    Message: Clone + 'a,
    Theme: text_input::Catalog + 'a,
    Renderer: text::Renderer + 'a,
{
    fn from(
        input: ContextInput<'a, Message, Theme, Renderer>,
    ) -> Element<'a, Message, Theme, Renderer> {
        Element::new(input)
    }
}

struct ContextMenuOverlay<'a, Message, Theme, Renderer>
where
    Theme: text_input::Catalog,
    Renderer: text::Renderer,
{
    menu: &'a mut Option<MenuState>,
    input_state: &'a mut text_input::State<Renderer::Paragraph>,
    value: &'a str,
    is_secure: bool,
    locked: bool,
    on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
    on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
    style: Option<InputStyleFn<'a, Theme>>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MenuAction {
    Cut,
    Copy,
    Paste,
    SelectAll,
}

impl MenuAction {
    const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];

    fn label(self) -> &'static str {
        match self {
            Self::Cut => "Cut",
            Self::Copy => "Copy",
            Self::Paste => "Paste",
            Self::SelectAll => "Select All",
        }
    }
}

const MENU_WIDTH: f32 = 136.0;
const ITEM_HEIGHT: f32 = 28.0;
const TEXT_SIZE: f32 = 13.0;
const MENU_PADDING_X: f32 = 10.0;

impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
    for ContextMenuOverlay<'_, Message, Theme, Renderer>
where
    Theme: text_input::Catalog,
    Renderer: text::Renderer,
{
    fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
        let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
        let Some(menu) = self.menu.as_ref() else {
            return layout::Node::new(Size::ZERO);
        };
        let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
        let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));

        layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
    }

    fn draw(
        &self,
        renderer: &mut Renderer,
        theme: &Theme,
        _style: &renderer::Style,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
    ) {
        let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
        let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
        let bounds = layout.bounds();
        let viewport = Rectangle::INFINITE;

        renderer.fill_quad(
            renderer::Quad {
                bounds,
                border: Border {
                    radius: 5.0.into(),
                    width: 1.0,
                    color: active_style.border.color,
                },
                shadow: Shadow {
                    color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
                    offset: Vector::new(0.0, 4.0),
                    blur_radius: 10.0,
                },
                ..renderer::Quad::default()
            },
            active_style.background,
        );

        for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
            let item_bounds = item_bounds(bounds, index);
            let enabled = self.enabled(action);
            let hovered = enabled && cursor.is_over(item_bounds);

            if hovered {
                renderer.fill_quad(
                    renderer::Quad {
                        bounds: item_bounds,
                        border: Border {
                            radius: 3.0.into(),
                            ..Border::default()
                        },
                        ..renderer::Quad::default()
                    },
                    Background::Color(hovered_style.selection),
                );
            }

            renderer.fill_text(
                text::Text {
                    content: action.label().to_owned(),
                    bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
                    size: Pixels(TEXT_SIZE),
                    line_height: text::LineHeight::default(),
                    font: renderer.default_font(),
                    align_x: text::Alignment::Default,
                    align_y: alignment::Vertical::Center,
                    shaping: text::Shaping::Advanced,
                    wrapping: text::Wrapping::default(),
                },
                Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
                if enabled {
                    active_style.value
                } else {
                    disabled_color(active_style.value)
                },
                viewport,
            );
        }
    }

    fn update(
        &mut self,
        event: &Event,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        _renderer: &Renderer,
        clipboard: &mut dyn Clipboard,
        shell: &mut Shell<'_, Message>,
    ) {
        match event {
            Event::Keyboard(iced::keyboard::Event::KeyPressed {
                key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
                ..
            }) => {
                self.close(shell);
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                let Some(position) = cursor.position() else {
                    self.close(shell);
                    return;
                };
                let bounds = layout.bounds();

                if !bounds.contains(position) {
                    self.close(shell);
                    return;
                }

                if let Some(action) = self.hit_action(bounds, position) {
                    if self.enabled(action) {
                        self.perform(action, clipboard, shell);
                    }
                    self.close(shell);
                }
            }
            Event::Mouse(mouse::Event::ButtonPressed(_)) => {
                let should_close = cursor
                    .position()
                    .is_none_or(|position| !layout.bounds().contains(position));

                if should_close {
                    self.close(shell);
                }
            }
            _ => {}
        }
    }

    fn mouse_interaction(
        &self,
        layout: Layout<'_>,
        cursor: mouse::Cursor,
        _renderer: &Renderer,
    ) -> mouse::Interaction {
        let Some(position) = cursor.position() else {
            return mouse::Interaction::default();
        };

        if self.hit_action(layout.bounds(), position).is_some() {
            mouse::Interaction::Pointer
        } else {
            mouse::Interaction::default()
        }
    }
}

impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
where
    Theme: text_input::Catalog,
    Renderer: text::Renderer,
{
    fn enabled(&self, action: MenuAction) -> bool {
        let Some(menu) = self.menu.as_ref() else {
            return false;
        };
        let has_selection = menu.selection.0 != menu.selection.1;
        let has_value = !text_input::Value::new(self.value).is_empty();

        menu_action_enabled(
            action,
            has_selection,
            has_value,
            self.is_secure,
            self.locked,
        )
    }

    fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
        if !bounds.contains(position) {
            return None;
        }

        let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;

        MenuAction::ALL.get(index).copied()
    }

    fn perform(
        &mut self,
        action: MenuAction,
        clipboard: &mut dyn Clipboard,
        shell: &mut Shell<'_, Message>,
    ) {
        let Some(menu) = self.menu.as_ref().copied() else {
            return;
        };
        let (start, end) = menu.selection;

        match action {
            MenuAction::Cut => {
                let (edit, selected) = cut_selection(self.value, start, end);

                if let Some(selected) = selected {
                    clipboard.write(clipboard::Kind::Standard, selected);
                    self.publish_edit(edit, shell);
                }
            }
            MenuAction::Copy => {
                if let Some(selected) = copy_selection(self.value, start, end) {
                    clipboard.write(clipboard::Kind::Standard, selected);
                }
            }
            MenuAction::Paste => {
                let clip = sanitize_clip(
                    &clipboard
                        .read(clipboard::Kind::Standard)
                        .unwrap_or_default(),
                );
                let edit = paste(self.value, start, end, &clip);

                self.publish_paste(edit, shell);
            }
            MenuAction::SelectAll => {
                let (start, end) = select_all_range(self.value);

                self.input_state.select_range(start, end);
                shell.request_redraw();
            }
        }
    }

    fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
        if let Some(on_input) = &self.on_input {
            self.input_state.move_cursor_to(edit.cursor);
            shell.publish(on_input.as_ref()(edit.value));
            shell.request_redraw();
        }
    }

    fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
        self.input_state.move_cursor_to(edit.cursor);

        if let Some(on_paste) = &self.on_paste {
            shell.publish(on_paste.as_ref()(edit.value));
        } else if let Some(on_input) = &self.on_input {
            shell.publish(on_input.as_ref()(edit.value));
        }

        shell.request_redraw();
    }

    fn close(&mut self, shell: &mut Shell<'_, Message>) {
        *self.menu = None;
        shell.capture_event();
        shell.request_redraw();
    }
}

fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
    Rectangle {
        x: menu_bounds.x + 3.0,
        y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
        width: menu_bounds.width - 6.0,
        height: ITEM_HEIGHT,
    }
}

fn input_style<Theme: text_input::Catalog>(
    theme: &Theme,
    style: Option<&InputStyleFn<'_, Theme>>,
    status: text_input::Status,
) -> text_input::Style {
    if let Some(style) = style {
        style.as_ref()(theme, status)
    } else {
        let class = <Theme as text_input::Catalog>::default();

        theme.style(&class, status)
    }
}

fn disabled_color(color: Color) -> Color {
    Color {
        a: color.a * 0.45,
        ..color
    }
}

fn menu_action_enabled(
    action: MenuAction,
    has_selection: bool,
    has_value: bool,
    is_secure: bool,
    locked: bool,
) -> bool {
    match action {
        MenuAction::Cut => has_selection && !is_secure && !locked,
        MenuAction::Copy => has_selection && !is_secure,
        MenuAction::Paste => !locked,
        MenuAction::SelectAll => has_value,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn copy_selection_returns_middle_substring_and_empty_none() {
        assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
        assert_eq!(copy_selection("abcdef", 3, 3), None);
    }

    #[test]
    fn cut_selection_removes_range_and_copies_selection() {
        let (edit, clip) = cut_selection("abcdef", 2, 5);

        assert_eq!(
            edit,
            Edit {
                value: "abf".to_owned(),
                cursor: 2,
            }
        );
        assert_eq!(clip, Some("cde".to_owned()));

        let (edit, clip) = cut_selection("abcdef", 3, 3);
        assert_eq!(
            edit,
            Edit {
                value: "abcdef".to_owned(),
                cursor: 3,
            }
        );
        assert_eq!(clip, None);
    }

    #[test]
    fn sanitize_clip_strips_control_chars_keeps_text() {
        // An X11 PRIMARY selection commonly carries a trailing newline.
        assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc");
        assert_eq!(sanitize_clip("a\tb\r\nc"), "abc");
        // Non-control unicode is preserved.
        assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀");
        assert_eq!(sanitize_clip(""), "");
    }

    #[test]
    fn paste_replaces_selection_or_inserts_at_cursor() {
        assert_eq!(
            paste("abcdef", 2, 5, "XY"),
            Edit {
                value: "abXYf".to_owned(),
                cursor: 4,
            }
        );
        assert_eq!(
            paste("abcdef", 3, 3, "XY"),
            Edit {
                value: "abcXYdef".to_owned(),
                cursor: 5,
            }
        );
    }

    #[test]
    fn select_all_range_uses_grapheme_length() {
        assert_eq!(select_all_range(""), (0, 0));
        assert_eq!(select_all_range("abé🦀"), (0, 4));
    }

    #[test]
    fn unicode_selection_boundaries_are_grapheme_correct() {
        assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));

        let (edit, clip) = cut_selection("aé🦀z", 2, 3);
        assert_eq!(clip, Some("🦀".to_owned()));
        assert_eq!(
            edit,
            Edit {
                value: "aéz".to_owned(),
                cursor: 2,
            }
        );

        assert_eq!(
            paste("aéz", 2, 2, "🦀"),
            Edit {
                value: "aé🦀z".to_owned(),
                cursor: 3,
            }
        );
    }

    #[test]
    fn locked_menu_allows_copy_and_select_all_only() {
        assert!(!menu_action_enabled(
            MenuAction::Cut,
            true,
            true,
            false,
            true
        ));
        assert!(menu_action_enabled(
            MenuAction::Copy,
            true,
            true,
            false,
            true
        ));
        assert!(!menu_action_enabled(
            MenuAction::Paste,
            true,
            true,
            false,
            true
        ));
        assert!(menu_action_enabled(
            MenuAction::SelectAll,
            true,
            true,
            false,
            true
        ));

        assert!(!menu_action_enabled(
            MenuAction::Copy,
            false,
            true,
            false,
            true
        ));
        assert!(!menu_action_enabled(
            MenuAction::SelectAll,
            false,
            false,
            false,
            true
        ));
    }
}
