use super::*; use iced::advanced::renderer::Headless; use iced::{Element, Event, Point, Size, keyboard, mouse}; use iced_runtime::user_interface::{Cache, UserInterface}; use std::cell::Cell; const VALUE: &str = "Control portée alpha"; const SUBMIT: &str = "submit query"; #[derive(Default)] struct Clipboard(Option); impl iced::advanced::Clipboard for Clipboard { fn read(&self, _: iced::advanced::clipboard::Kind) -> Option { self.0.clone() } fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) { self.0 = Some(text); } } fn input(readonly: bool, observed: &Cell>) -> Element<'_, String> { // This is the same conditional-callback and style path as Hosted fields. // No synthetic cursor/selection state is injected into the widget tree. iced::widget::text_input("Search model names", VALUE) .id("readonly-query") .padding(10) .size(18) .on_input_maybe((!readonly).then_some(std::convert::identity::)) .on_submit(SUBMIT.to_owned()) .style(move |theme, status| { observed.set(Some(status)); selectable_input(theme, status) }) .into() } fn key( key: keyboard::Key, code: keyboard::key::Code, modifiers: keyboard::Modifiers, text: Option<&str>, ) -> Event { Event::Keyboard(keyboard::Event::KeyPressed { modified_key: key.clone(), key, physical_key: keyboard::key::Physical::Code(code), location: keyboard::Location::Standard, modifiers, text: text.map(Into::into), repeat: false, }) } fn modifiers(modifiers: keyboard::Modifiers) -> Event { Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) } fn near(pixel: &[u8], color: Color, tolerance: u8) -> bool { pixel[..3] .iter() .zip([color.r, color.g, color.b]) .all(|(byte, channel)| byte.abs_diff((channel * 255.0).round() as u8) <= tolerance) } #[tokio::test] async fn mounted_readonly_input_keeps_selection_copy_and_readable_paint() { let mut renderer = ::new(iced::Font::DEFAULT, 18.into(), None) .await .expect("mounted input test requires the supported WGPU headless renderer"); let theme = theme(); let size = Size::new(480.0, 64.0); let cursor = mouse::Cursor::Available(Point::new(20.0, 18.0)); let command = keyboard::Modifiers::COMMAND; let no_modifiers = keyboard::Modifiers::empty(); for submit_then_readonly in [false, true] { let observed = Cell::new(None); let mut clipboard = Clipboard::default(); let mut messages = Vec::new(); let mut ui = UserInterface::build( input(!submit_then_readonly, &observed), size, Cache::new(), &mut renderer, ); let _ = ui.update( &[ Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), modifiers(command), key( keyboard::Key::Character("a".into()), keyboard::key::Code::KeyA, command, None, ), modifiers(no_modifiers), ], cursor, &mut renderer, &mut clipboard, &mut messages, ); assert!(messages.is_empty(), "selection must not mutate the field"); if submit_then_readonly { let _ = ui.update( &[key( keyboard::Key::Named(keyboard::key::Named::Enter), keyboard::key::Code::Enter, no_modifiers, None, )], cursor, &mut renderer, &mut clipboard, &mut messages, ); assert_eq!(messages, [SUBMIT.to_owned()]); messages.clear(); // Model the Hosted submit -> busy rebuild while retaining Iced's // actual focus/selection cache, value and widget identity. let cache = ui.into_cache(); ui = UserInterface::build(input(true, &observed), size, cache, &mut renderer); } let copy = [ modifiers(command), key( keyboard::Key::Character("c".into()), keyboard::key::Code::KeyC, command, None, ), modifiers(no_modifiers), ]; let _ = ui.update(©, cursor, &mut renderer, &mut clipboard, &mut messages); assert_eq!(clipboard.0.as_deref(), Some(VALUE)); assert!(messages.is_empty(), "copy must not publish an edit"); let _ = ui.update( &[ key( keyboard::Key::Character("z".into()), keyboard::key::Code::KeyZ, no_modifiers, Some("z"), ), key( keyboard::Key::Named(keyboard::key::Named::Backspace), keyboard::key::Code::Backspace, no_modifiers, None, ), ], cursor, &mut renderer, &mut clipboard, &mut messages, ); assert!( messages.is_empty(), "no callback means no edit, even while selected" ); clipboard.0 = None; let _ = ui.update(©, cursor, &mut renderer, &mut clipboard, &mut messages); assert_eq!( clipboard.0.as_deref(), Some(VALUE), "readonly input must retain its selected value" ); ui.draw( &mut renderer, &theme, &iced::advanced::renderer::Style { text_color: INK }, cursor, ); assert_eq!(observed.get(), Some(text_input::Status::Disabled)); let pixels = Headless::screenshot(&mut renderer, Size::new(480, 64), 1.0, BG); let style = selectable_input(&theme, text_input::Status::Disabled); let selection_pixels: Vec<_> = pixels .as_chunks::<4>() .0 .iter() .enumerate() .filter(|(_, pixel)| near(*pixel, style.selection, 3)) .map(|(index, _)| (index % 480, index / 480)) .collect(); assert!( selection_pixels.len() > 100, "actual readonly range must be painted" ); let left = selection_pixels.iter().map(|p| p.0).min().unwrap(); let right = selection_pixels.iter().map(|p| p.0).max().unwrap(); let top = selection_pixels.iter().map(|p| p.1).min().unwrap(); let bottom = selection_pixels.iter().map(|p| p.1).max().unwrap(); let glyph_pixels = pixels .as_chunks::<4>() .0 .iter() .enumerate() .filter(|(index, pixel)| { let (x, y) = (index % 480, index / 480); (left..=right).contains(&x) && (top..=bottom).contains(&y) && near(*pixel, style.value, 12) }) .count(); assert!( glyph_pixels > 10, "selected glyphs must render with the dark value ink" ); // Check specified colors separately from antialiased glyph-edge pixels. require_contrast( "painted readonly selected value", style.value, style.selection, 4.5, ); require_contrast( "painted readonly selection", style.selection, solid(style.background), 3.0, ); } }