From afa74a305980f611437dd3aa2f937d36e9266cfe Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 12 Sep 2026 17:28:16 -0700 Subject: [PATCH] Keep read-only input selections readable and copyable --- crates/oa-desktop/src/hosted/view.rs | 3 + crates/oa-desktop/src/style.rs | 10 +- crates/oa-desktop/src/style/tests.rs | 72 +++++- crates/oa-desktop/src/style/tests/readonly.rs | 226 ++++++++++++++++++ 4 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 crates/oa-desktop/src/style/tests/readonly.rs diff --git a/crates/oa-desktop/src/hosted/view.rs b/crates/oa-desktop/src/hosted/view.rs index 232ec831..e01491a0 100644 --- a/crates/oa-desktop/src/hosted/view.rs +++ b/crates/oa-desktop/src/hosted/view.rs @@ -67,10 +67,12 @@ fn dialog(app: &App) -> Element<'_, Message> { let mut content = column![ label("SERVICE ADDRESS"), text_input("http://127.0.0.1:8787", &state.base) + .style(style::selectable_input) .padding(10) .on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))), label("BEARER TOKEN - KEPT IN MEMORY ONLY"), text_input("Optional for a loopback service", &state.token.0) + .style(style::selectable_input) .secure(true) .padding(10) .on_input_maybe( @@ -173,6 +175,7 @@ fn dialog(app: &App) -> Element<'_, Message> { content = content.push(text("Hosted model catalog").size(17)).push( row![ text_input("Search model names", &state.query) + .style(style::selectable_input) .padding(9) .on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value)))) .on_submit(Message::Hosted(Event::Connect)), diff --git a/crates/oa-desktop/src/style.rs b/crates/oa-desktop/src/style.rs index c791b4eb..41233887 100644 --- a/crates/oa-desktop/src/style.rs +++ b/crates/oa-desktop/src/style.rs @@ -1,7 +1,7 @@ use iced::{ Border, Color, Theme, theme::palette::{Extended, Pair}, - widget::{button, container, text_editor}, + widget::{button, container, text_editor, text_input}, }; use oa_core::Kind; pub const BG: Color = Color::from_rgb(0.945, 0.956, 0.969); @@ -70,6 +70,14 @@ pub fn source_editor(theme: &Theme, status: text_editor::Status) -> text_editor: style.selection = SOURCE_SELECTION; style } +/// Inputs without an edit callback still support selection and copy in Iced. +pub fn selectable_input(theme: &Theme, status: text_input::Status) -> text_input::Style { + let mut style = text_input::default(theme, status); + if status == text_input::Status::Disabled { + style.value = INK; + } + style +} pub fn card(_: &Theme) -> container::Style { container::Style { background: Some(Color::WHITE.into()), diff --git a/crates/oa-desktop/src/style/tests.rs b/crates/oa-desktop/src/style/tests.rs index 04ef9078..6281b1d8 100644 --- a/crates/oa-desktop/src/style/tests.rs +++ b/crates/oa-desktop/src/style/tests.rs @@ -5,6 +5,8 @@ use iced::{ }; use oa_core::language::TokenClass; +mod readonly; + fn luminance(color: Color) -> f64 { [color.r, color.g, color.b] .into_iter() @@ -155,8 +157,74 @@ fn default_input_styles_keep_ordinary_values_and_visible_selection() { surface, 4.5, ); - // Selection interaction is unavailable while disabled; its unused selected - // value pair is not an active text contrast requirement. + // Iced still permits selection/copy without an edit callback. Its default + // disabled pair is a negative control; conditional Hosted inputs use the + // selectable_input adapter below instead of inheriting this unreadable pair. + assert!(contrast(disabled.value, disabled.selection) < 4.5); +} + +#[test] +fn conditional_input_styles_preserve_readonly_selected_value_contrast() { + let theme = theme(); + for status in [ + text_input::Status::Active, + text_input::Status::Hovered, + text_input::Status::Focused { is_hovered: false }, + text_input::Status::Focused { is_hovered: true }, + text_input::Status::Disabled, + ] { + let original = text_input::default(&theme, status); + let style = selectable_input(&theme, status); + assert_eq!(style.background, original.background); + assert_eq!(style.border, original.border); + assert_eq!(style.placeholder, original.placeholder); + assert_eq!(style.value, INK); + require_contrast( + "conditional input value", + style.value, + solid(style.background), + 4.5, + ); + require_contrast("readonly selected value", style.value, style.selection, 4.5); + require_contrast( + "readonly range", + style.selection, + solid(style.background), + 3.0, + ); + } +} + +#[test] +fn ordinary_editable_editor_styles_keep_their_own_selected_value_readable() { + let theme = theme(); + for status in [ + text_editor::Status::Active, + text_editor::Status::Hovered, + text_editor::Status::Focused { is_hovered: false }, + text_editor::Status::Focused { is_hovered: true }, + ] { + let style = text_editor::default(&theme, status); + assert_eq!(style.value, INK); + require_contrast( + "ordinary editor value", + style.value, + solid(style.background), + 4.5, + ); + require_contrast( + "ordinary editor selected value", + style.value, + style.selection, + 4.5, + ); + require_contrast( + "ordinary editor range", + style.selection, + solid(style.background), + 3.0, + ); + } } #[test] diff --git a/crates/oa-desktop/src/style/tests/readonly.rs b/crates/oa-desktop/src/style/tests/readonly.rs new file mode 100644 index 00000000..07d9ea7e --- /dev/null +++ b/crates/oa-desktop/src/style/tests/readonly.rs @@ -0,0 +1,226 @@ +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 + .chunks_exact(4) + .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 + .chunks_exact(4) + .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, + ); + } +}