Keep read-only input selections readable and copyable

This commit is contained in:
2026-09-12 17:29:24 -07:00
parent 200aa90689
commit afa74a3059
4 changed files with 308 additions and 3 deletions
+3
View File
@@ -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)),
+9 -1
View File
@@ -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()),
+70 -2
View File
@@ -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]
@@ -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<String>);
impl iced::advanced::Clipboard for Clipboard {
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
self.0.clone()
}
fn write(&mut self, _: iced::advanced::clipboard::Kind, text: String) {
self.0 = Some(text);
}
}
fn input(readonly: bool, observed: &Cell<Option<text_input::Status>>) -> 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::<String>))
.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 = <iced::Renderer as Headless>::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(&copy, 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(&copy, 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,
);
}
}