diff --git a/Cargo.toml b/Cargo.toml index da492b9..04ee9ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ async-trait = "0.1.89" base64 = "0.22.1" bytes = "1.11.1" dirs = "6.0.0" -iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] } +iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] } # W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep # the codec surface small). The matching native file picker (`rfd`) is platform- # gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows). diff --git a/src/app/mod.rs b/src/app/mod.rs index 87788c1..db19f3d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -11,12 +11,14 @@ use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; use crate::presence::PresenceMode; use crate::theme::{AppTheme, Palette}; +use crate::widget::context_input::context_input; use iced::widget::{ - container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, + container, column, row, text, button, scrollable, slider, checkbox, pick_list, radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area, rich_text, span, responsive, }; +use iced::widget::text_input; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; use iced::{ Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse, @@ -2624,7 +2626,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { let nickname_input = column![ text("Nickname").size(14).color(color_subtext), vertical_space(4.0), - text_input("Enter nickname...", &state.name) + context_input("Enter nickname...", &state.name) .on_input(AppMessage::NicknameChanged) .style(t_style) .padding(10) @@ -2633,7 +2635,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { // Optional cosmetic room label (W7) above the Create button: it rides in the // minted ticket so everyone who joins inherits "in ". Enter also creates. let create_group = column![ - text_input("Room name (optional)", &state.room_name_input) + context_input("Room name (optional)", &state.room_name_input) .on_input(AppMessage::RoomNameChanged) .on_submit(AppMessage::CreatePressed) .style(t_style) @@ -2649,7 +2651,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { let join_group = column![ text("Join Existing Room").size(14).color(color_subtext), vertical_space(4.0), - text_input("Paste room ticket here...", &state.ticket_input) + context_input("Paste room ticket here...", &state.ticket_input) .on_input(AppMessage::TicketInputChanged) .style(t_style) .padding(10), @@ -2875,7 +2877,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { }; friend_rows = friend_rows.push( row![ - text_input("name", &f.name) + context_input("name", &f.name) .on_input(move |v| AppMessage::RenameFriend(fid, v)) .style(t_style) .padding(6) @@ -2909,13 +2911,13 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { }; let add_form = column![ - text_input("Friend's node ID", &state.friend_add_id) + context_input("Friend's node ID", &state.friend_add_id) .on_input(AppMessage::FriendAddIdChanged) .style(t_style) .padding(6), vertical_space(6.0), row![ - text_input("Name (optional)", &state.friend_add_name) + context_input("Name (optional)", &state.friend_add_name) .on_input(AppMessage::FriendAddNameChanged) .style(t_style) .padding(6) @@ -3164,7 +3166,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { horizontal_space(), validation_widget, ].spacing(6).align_y(iced::alignment::Vertical::Center), - text_input("Default (embedded)...", path) + context_input("Default (embedded)...", path) .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val)) .style(t_style) .padding(8) @@ -3834,10 +3836,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text("Steam games are detected automatically. For other launchers, map an executable name to a display name.") .size(11).color(color_subtext), row![ - text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input) + context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input) .on_input(AppMessage::GameMapExeChanged) .width(iced::Length::Fill), - text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input) + context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input) .on_input(AppMessage::GameMapNameChanged) .width(iced::Length::Fill), button(text("Add").size(13)).on_press(AppMessage::AddGameMapping), @@ -4836,7 +4838,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_press(AppMessage::PickAttachmentFile) .style(b_style(color_surface, color_overlay, color_text, 6.0)) .padding(8), - text_input("Message the room…", &state.chat_input) + context_input("Message the room…", &state.chat_input) .on_input(AppMessage::ChatInputChanged) .on_submit(AppMessage::ChatSubmit) .style(t_style) diff --git a/src/lib.rs b/src/lib.rs index 8629613..95f4c9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod discovery; pub mod hotkeys; pub mod files; pub mod game; +pub mod widget; use std::fs::File; use std::path::{Path, PathBuf}; diff --git a/src/widget/context_input.rs b/src/widget/context_input.rs new file mode 100644 index 0000000..d184a4c --- /dev/null +++ b/src/widget/context_input.rs @@ -0,0 +1,894 @@ +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::{ + alignment, Background, Border, Color, Element, Event, Length, Padding, + Pixels, Point, Rectangle, Shadow, Size, Vector, +}; +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 { + 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) { + 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, + } +} + +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 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 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, + on_input: Option Message + 'a>>, + on_paste: Option Message + 'a>>, + style: Option>, +} + +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, + on_input: None, + on_paste: None, + style: None, + } + } + + pub fn id(mut self, id: impl Into) -> 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 on_input( + mut self, + on_input: impl Fn(String) -> Message + 'a, + ) -> Self { + let on_input: Rc 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) -> 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 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) -> Self { + self.input = self.input.icon(icon); + self + } + + pub fn width(mut self, width: impl Into) -> Self { + self.input = self.input.width(width); + self + } + + pub fn padding>(mut self, padding: P) -> Self { + self.input = self.input.padding(padding); + self + } + + pub fn size(mut self, size: impl Into) -> Self { + self.input = self.input.size(size); + self + } + + pub fn line_height( + mut self, + line_height: impl Into, + ) -> Self { + self.input = self.input.line_height(line_height); + self + } + + pub fn align_x( + mut self, + alignment: impl Into, + ) -> 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>, + { + 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>) -> Self { + self.input = self.input.class(class); + self.style = None; + self + } +} + +#[derive(Default)] +struct ContextInputState { + menu: Option, +} + +#[derive(Debug, Clone, Copy)] +struct MenuState { + anchor: Point, + selection: (usize, usize), +} + +impl Widget + for ContextInput<'_, Message, Theme, Renderer> +where + Message: Clone, + Theme: text_input::Catalog, + Renderer: text::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(ContextInputState::default()) + } + + fn children(&self) -> Vec { + 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 { + Widget::size(&self.input) + } + + fn size_hint(&self) -> Size { + 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::>(); + 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::().menu = + cursor.position().map(|anchor| MenuState { + anchor, + selection, + }); + + 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> { + let Tree { + state, children, .. + } = tree; + let menu = &mut state.downcast_mut::().menu; + + if menu.is_none() { + return None; + } + + let input_state = children[0] + .state + .downcast_mut::>(); + + Some(overlay::Element::new(Box::new(ContextMenuOverlay { + menu, + input_state, + value: &self.value, + is_secure: self.is_secure, + on_input: self.on_input.clone(), + on_paste: self.on_paste.clone(), + style: self.style.clone(), + }))) + } +} + +impl<'a, Message, Theme, Renderer> + From> + 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, + input_state: &'a mut text_input::State, + value: &'a str, + is_secure: bool, + on_input: Option Message + 'a>>, + on_paste: Option Message + 'a>>, + style: Option>, +} + +#[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 overlay::Overlay + 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 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(); + + match action { + MenuAction::Cut | MenuAction::Copy => { + has_selection && !self.is_secure + } + MenuAction::Paste => true, + MenuAction::SelectAll => has_value, + } + } + + fn hit_action( + &self, + bounds: Rectangle, + position: Point, + ) -> Option { + 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 = clipboard + .read(clipboard::Kind::Standard) + .unwrap_or_default() + .chars() + .filter(|c| !c.is_control()) + .collect::(); + 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: &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 = ::default(); + + theme.style(&class, status) + } +} + +fn disabled_color(color: Color) -> Color { + Color { + a: color.a * 0.45, + ..color + } +} + +#[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 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, + } + ); + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs new file mode 100644 index 0000000..a1ead38 --- /dev/null +++ b/src/widget/mod.rs @@ -0,0 +1 @@ +pub mod context_input;