Keep read-only input selections readable and copyable
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(©, 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user