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![
|
let mut content = column![
|
||||||
label("SERVICE ADDRESS"),
|
label("SERVICE ADDRESS"),
|
||||||
text_input("http://127.0.0.1:8787", &state.base)
|
text_input("http://127.0.0.1:8787", &state.base)
|
||||||
|
.style(style::selectable_input)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
|
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Base(value)))),
|
||||||
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
|
label("BEARER TOKEN - KEPT IN MEMORY ONLY"),
|
||||||
text_input("Optional for a loopback service", &state.token.0)
|
text_input("Optional for a loopback service", &state.token.0)
|
||||||
|
.style(style::selectable_input)
|
||||||
.secure(true)
|
.secure(true)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
.on_input_maybe(
|
.on_input_maybe(
|
||||||
@@ -173,6 +175,7 @@ fn dialog(app: &App) -> Element<'_, Message> {
|
|||||||
content = content.push(text("Hosted model catalog").size(17)).push(
|
content = content.push(text("Hosted model catalog").size(17)).push(
|
||||||
row![
|
row![
|
||||||
text_input("Search model names", &state.query)
|
text_input("Search model names", &state.query)
|
||||||
|
.style(style::selectable_input)
|
||||||
.padding(9)
|
.padding(9)
|
||||||
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
|
.on_input_maybe(enabled.then_some(|value| Message::Hosted(Event::Query(value))))
|
||||||
.on_submit(Message::Hosted(Event::Connect)),
|
.on_submit(Message::Hosted(Event::Connect)),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use iced::{
|
use iced::{
|
||||||
Border, Color, Theme,
|
Border, Color, Theme,
|
||||||
theme::palette::{Extended, Pair},
|
theme::palette::{Extended, Pair},
|
||||||
widget::{button, container, text_editor},
|
widget::{button, container, text_editor, text_input},
|
||||||
};
|
};
|
||||||
use oa_core::Kind;
|
use oa_core::Kind;
|
||||||
pub const BG: Color = Color::from_rgb(0.945, 0.956, 0.969);
|
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.selection = SOURCE_SELECTION;
|
||||||
style
|
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 {
|
pub fn card(_: &Theme) -> container::Style {
|
||||||
container::Style {
|
container::Style {
|
||||||
background: Some(Color::WHITE.into()),
|
background: Some(Color::WHITE.into()),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use iced::{
|
|||||||
};
|
};
|
||||||
use oa_core::language::TokenClass;
|
use oa_core::language::TokenClass;
|
||||||
|
|
||||||
|
mod readonly;
|
||||||
|
|
||||||
fn luminance(color: Color) -> f64 {
|
fn luminance(color: Color) -> f64 {
|
||||||
[color.r, color.g, color.b]
|
[color.r, color.g, color.b]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -155,8 +157,74 @@ fn default_input_styles_keep_ordinary_values_and_visible_selection() {
|
|||||||
surface,
|
surface,
|
||||||
4.5,
|
4.5,
|
||||||
);
|
);
|
||||||
// Selection interaction is unavailable while disabled; its unused selected
|
// Iced still permits selection/copy without an edit callback. Its default
|
||||||
// value pair is not an active text contrast requirement.
|
// 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]
|
#[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