diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index cdeca225b..9583b893e 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -253,35 +253,27 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option { let t = Tokens::get(ui.ctx()); let id = ui.scope_id().with("swatch-hex"); - let editing = ui.memory(|m| m.has_focus(id)); - // As in `widgets::hex_field`: until the user types into it, the field shows `hex` as it is now - // (#1692). - let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|m| m.get_temp::(edited_id)).unwrap_or(false); - let mut buf: String = if edited { ui.data_mut(|m| m.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(2) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - crate::theme::strong_selected_text(ui); - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .align(egui::Align2::LEFT_CENTER) - .desired_width(70.0) - .char_limit(7), - ) - }) - .inner; - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|m| { - m.insert_temp(id, buf.clone()); - m.insert_temp(edited_id, (edited || resp.changed()) && focused); - }); - (resp.lost_focus() && buf != hex).then(|| parse_hex(&buf)).flatten() + widgets::hex_text(ui, id, hex, |ui, buf, editing| { + egui::Frame::NONE + .fill(t.input) + .stroke(egui::Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(2) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + crate::theme::strong_selected_text(ui); + ui.add( + egui::TextEdit::singleline(buf) + .id(id) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(70.0) + .char_limit(widgets::HEX_FIELD_CHARS), + ) + }) + .inner + }) + .as_deref() + .and_then(parse_hex) } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 8ab9cb9b8..555b21d1f 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -952,6 +952,115 @@ fn tab_through_the_hex_field_keeps_the_value_typed_before_it() { } } +/// A hex code pasted into the # field of the Color Picker or Swatch Options after a click replaces +/// its digits, with or without `#`. Enter, Tab and a press on another field each take it. A paste +/// that is not a color changes nothing, and Enter then closes on the color from before it. +#[test] +fn a_hex_code_pasted_into_the_hex_field_replaces_its_digits() { + let mut app = app(); + app.run("swatch.new", json!({"name": "Sky", "color": "#3366cc"})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + // One frame of the dialog layer: every text drawn, with its center. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts + }; + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + // The context keeps a key down until it is let go, and a dialog ignores a repeat of Enter. + let release = |key| egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + type Open = fn(&mut VectorcraftApp); + type Applied = fn(&VectorcraftApp) -> String; + let dialogs: [(&str, Open, Applied, &str, &str); 2] = [ + ( + "Color Picker", + |app| drop(app.run("ui.colorPicker", json!({"stroke": false, "color": "#ffffff"}))), + |app| crate::panels::current_paints(app).0.color().unwrap().to_hex(), + "FFFFFF", + "255", + ), + ( + "Swatch Options", + |app| drop(app.run("ui.swatchOptions", json!({"name": "Sky"}))), + |app| app.session.active().unwrap().doc.swatch("Sky").unwrap().paint.color().unwrap().to_hex(), + "3366CC", + "204", + ), + ]; + for (name, open, applied, hex, other) in dialogs { + // Opens the dialog and clicks into its # field. → The texts it shows. + let click_in = |app: &mut VectorcraftApp| { + open(app); + for _ in 0..3 { + frame(app, vec![]); + } + let texts = frame(app, vec![]); + let at = texts.iter().find(|(t, _)| t == hex).unwrap_or_else(|| panic!("{name}: no # field shows {hex}")).1; + frame(app, vec![egui::Event::PointerMoved(at), button(at, true)]); + frame(app, vec![button(at, false)]); + frame(app, vec![]); + texts + }; + let before = applied(&app); + // The click selects the digits, so typing replaces them. + click_in(&mut app); + frame(&mut app, vec![egui::Event::Text("00FF00".into())]); + assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: typing after a click replaces the digits"); + cancel(&mut app); + frame(&mut app, vec![]); + // A paste that is not a color leaves the field as it was. + for paste in ["hello", "12345", "#12345", "12345678", "#00FF00 and more", ""] { + click_in(&mut app); + let shows = frame(&mut app, vec![egui::Event::Paste(paste.into())]); + assert!(shows.iter().any(|(t, _)| t == hex), "{name}: the # field keeps {hex} after pasting {paste:?}"); + frame(&mut app, vec![key(egui::Key::Enter)]); + frame(&mut app, vec![release(egui::Key::Enter)]); + assert!(app.ui.dialog.is_none(), "{name}: Enter closes the dialog after pasting {paste:?}"); + assert_eq!(applied(&app), before, "{name}: pasting {paste:?} changes no color"); + } + // Tab takes the pasted code. + click_in(&mut app); + frame(&mut app, vec![egui::Event::Paste("#00FF00".into())]); + frame(&mut app, vec![key(egui::Key::Tab)]); + frame(&mut app, vec![]); + assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: Tab takes the pasted code"); + cancel(&mut app); + frame(&mut app, vec![]); + // So does a press on another field, which is drawn before the # field. + let texts = click_in(&mut app); + let away = texts.iter().find(|(t, _)| t == other).unwrap_or_else(|| panic!("{name}: no field shows {other}: {texts:?}")).1; + frame(&mut app, vec![egui::Event::Paste("00FF00".into())]); + frame(&mut app, vec![egui::Event::PointerMoved(away), button(away, true)]); + frame(&mut app, vec![button(away, false)]); + frame(&mut app, vec![]); + assert!(frame(&mut app, vec![]).iter().any(|(t, _)| t == "00FF00"), "{name}: a press elsewhere takes the pasted code"); + cancel(&mut app); + frame(&mut app, vec![]); + // Enter takes it and closes the dialog with the color. + for paste in ["00FF00", "#00ff00", " #00FF00\n", "0f0"] { + click_in(&mut app); + frame(&mut app, vec![egui::Event::Paste(paste.into())]); + frame(&mut app, vec![key(egui::Key::Enter)]); + frame(&mut app, vec![release(egui::Key::Enter)]); + assert!(app.ui.dialog.is_none(), "{name}: Enter closes the dialog after pasting {paste:?}"); + assert_eq!(applied(&app), "#00ff00", "{name}: Enter applies {paste:?}"); + app.run("swatch.edit", json!({"name": "Sky", "color": "#3366cc"})).unwrap(); + } + } +} + /// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed /// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid. #[test] diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs index c88f79613..97255bd22 100644 --- a/crates/ui-egui/src/dialogs/tests_perspective.rs +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -71,6 +71,50 @@ fn define_grid_opens_on_the_grid_and_applies_its_fields() { assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); } +/// A three-digit code pasted or typed into a Define Grid color field sets that grid color. +#[test] +fn define_grid_takes_a_three_digit_color() { + let mut app = app(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + // One frame of the dialog layer: every text drawn, with its center. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::show(app, ui.ctx())); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // The context keeps a key down until it is let go, and a dialog ignores a repeat of Enter. + let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + for (how, input) in [("pasted", egui::Event::Paste("0f0".into())), ("typed", egui::Event::Text("0f0".into()))] { + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + set(&mut app, "leftColor", json!("#123456")); + for _ in 0..3 { + frame(&mut app, vec![]); + } + let texts = frame(&mut app, vec![]); + let at = texts.iter().find(|(t, _)| t == "123456").unwrap_or_else(|| panic!("no Left Grid field in {texts:?}")).1; + frame(&mut app, vec![egui::Event::PointerMoved(at), button(at, true)]); + frame(&mut app, vec![button(at, false)]); + frame(&mut app, vec![]); + frame(&mut app, vec![input]); + frame(&mut app, vec![enter(true)]); + frame(&mut app, vec![enter(false)]); + assert_eq!(kind(&app), None, "{how}: Enter closes the dialog"); + assert_eq!(grid(&app).left_color.hex(), "#00ff00", "{how}: the grid takes the color"); + } +} + #[test] fn define_grid_is_a_view_menu_item() { let app = app(); diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 1cd02a9d2..d4dc7775e 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -159,6 +159,106 @@ fn typing_into_a_hex_field_commits_it() { } } +/// Keyboard focus selects a hex field's digits. A pasted code replaces them wherever the caret is, +/// hex digits that fit edit them in place, and a paste that is not a color leaves them as they were. +/// Typing digit by digit is not affected. +#[test] +fn a_paste_into_a_hex_field_replaces_or_edits_its_digits() { + let hex = Cell::new(None); + let hex_id = Cell::new(egui::Id::NULL); + let draw = |ui: &mut egui::Ui| { + let red = widgets::plain_field(ui, "red", 255.0, "", 0, 80.0); + hex_id.set(ui.scope_id().with("hex")); + hex.set(widgets::hex_field(ui, "hex", "FF8000")); + red + }; + let (tab, end) = (|| key(Key::Tab, Modifiers::NONE), || key(Key::End, Modifiers::NONE)); + let paste = |text: &str| vec![Event::Paste(text.into())]; + let digit = |text: &str| vec![Event::Text(text.into())]; + // Its name, the events after the field has the focus, the text they leave and what Tab commits. + type Case<'a> = (&'a str, Vec>, &'a str, Option<&'a str>); + let select_left = |n: usize| std::iter::once(end()).chain(std::iter::repeat_n(key(Key::ArrowLeft, Modifiers::SHIFT), n)).collect::>(); + let cases: [Case; 25] = [ + ("three selected, three digits", vec![select_left(3), paste("ABC")], "FF8ABC", Some("FF8ABC")), + ("three characters, caret, three digits", vec![digit("FF8"), paste("ABC")], "FF8ABC", Some("FF8ABC")), + ("five characters, two selected, three digits", vec![digit("12345"), select_left(2), paste("ABC")], "123ABC", Some("123ABC")), + ("two characters, caret, three digits", vec![digit("12"), paste("ABC")], "ABC", Some("AABBCC")), + ("four selected, three digits", vec![select_left(4), paste("0f0")], "0f0", Some("00ff00")), + ("selected digits", vec![paste("00FF00")], "00FF00", Some("00FF00")), + ("caret, a code", vec![vec![end()], paste("00FF00")], "00FF00", Some("00FF00")), + ("caret, a code with #", vec![vec![end()], paste("#00ff00")], "00ff00", Some("00ff00")), + ("caret, three digits", vec![vec![end()], paste("0f0")], "0f0", Some("00ff00")), + ("caret, a line break after the code", vec![vec![end()], paste("#00FF00\n")], "00FF00", Some("00FF00")), + ("caret, a word", vec![vec![end()], paste("hello")], "FF8000", None), + ("caret, too few digits", vec![vec![end()], paste("12")], "FF8000", None), + ("selected digits, too many digits", vec![paste("12345678")], "FF8000", None), + ("part selected, digits that fit", vec![select_left(2), paste("AB")], "FF80AB", Some("FF80AB")), + ("typed # and two digits, four digits", vec![digit("#12"), paste("3456")], "#123456", Some("123456")), + ("typed # and three digits, three digits", vec![digit("#FFF"), paste("ABC")], "#FFFABC", Some("FFFABC")), + ("typed # and two digits, three digits", vec![digit("#12"), paste("ABC")], "ABC", Some("AABBCC")), + ("typed #, two digits", vec![digit("#"), paste("12")], "#12", Some("12")), + ("empty field, two digits", vec![vec![key(Key::Backspace, Modifiers::NONE)], paste("12")], "12", Some("12")), + ("the same color in other letters", vec![paste("ff8000")], "ff8000", None), + ("typed digit by digit", ["0", "0", "F", "F", "0", "0"].map(digit).to_vec(), "00FF00", Some("00FF00")), + ("typed with #", vec![digit("#00FF00")], "#00FF00", Some("00FF00")), + ("typed in part", vec![digit("00")], "00", Some("00")), + ("typed three digits with #", vec![digit("#0f0")], "#0f0", Some("00ff00")), + ("typed three letters that are not digits", vec![digit("xyz")], "xyz", Some("xyz")), + ]; + for (name, events, shows, commits) in cases { + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![], &draw); + let selected = egui::TextEdit::load_state(&f.ctx, hex_id.get()).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 6)), "{name}: keyboard focus selects the digits"); + for events in events { + f.frame(events, &draw); + assert_eq!(hex.take(), None, "{name}: nothing is committed before Tab"); + } + assert_eq!(f.ctx.data(|d| d.get_temp::(hex_id.get())).as_deref(), Some(shows), "{name}: the text"); + f.frame(vec![tab()], &draw); + assert_eq!(hex.take().as_deref(), commits, "{name}: what Tab commits"); + } + // Escape drops a pasted code. + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![tab()], &draw); + f.frame(paste("00FF00"), &draw); + assert_eq!(hex.take(), None); + f.frame(vec![key(Key::Escape, Modifiers::NONE)], &draw); + assert_eq!(hex.take(), None, "Escape commits nothing"); + f.frame(vec![], &draw); + assert_eq!(f.ctx.data(|d| d.get_temp::(hex_id.get())).as_deref(), Some("FF8000"), "the field shows its color again"); + // A press outside the field commits in the frame of the press, before the press acts. + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(vec![tab()], &draw); + f.frame(vec![tab()], &draw); + f.frame(paste("00FF00"), &draw); + let away = Pos2::new(390.0, 190.0); + f.frame(vec![Event::PointerMoved(away)], &draw); + assert_eq!(hex.take(), None); + f.frame(vec![Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }], &draw); + assert_eq!(hex.take().as_deref(), Some("00FF00"), "the press commits"); + // Three digits that spell the color the field holds commit nothing. + let same = |ui: &mut egui::Ui| { + let red = widgets::plain_field(ui, "red", 255.0, "", 0, 80.0); + hex.set(widgets::hex_field(ui, "hex", "AABBCC")); + red + }; + let mut f = Field::new(); + f.frame(vec![], &same); + f.frame(vec![tab()], &same); + f.frame(vec![tab()], &same); + f.frame(paste("abc"), &same); + assert_eq!(hex.take(), None); + f.frame(vec![tab()], &same); + assert_eq!(hex.take(), None, "abc over AABBCC commits nothing"); +} + /// #1692: the Stroke panel's dash and gap fields ([`widgets::opt_field`]) are blank while the /// selected objects' dashes differ. Tab out of the dash after typing into it gives the gap the /// focus; the gap then shows the dash's length, which the committed dash repeats as its gap. Tab diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a6daf1fa..e3972c8aa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -32,7 +32,7 @@ pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, s #[cfg(test)] use chips::gradient_chip_colors; pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; -pub(crate) use color::pointer_phase; +pub(crate) use color::{HEX_FIELD_CHARS, hex_text, pointer_phase}; pub use color::{ Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider, color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, segment_color, diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index cb44bfe04..72f74f7b9 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -1,6 +1,6 @@ use super::labels::{label_field, name_field, take_previous_label}; use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; -use super::text::{after_shift_enter, select_all, shift_enter_keeps_focus}; +use super::text::{after_shift_enter, select_all, select_all_on_focus, shift_enter_keeps_focus}; use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2}; /// Drag phase of a live slider/handle: previews while dragging, commits on release. @@ -432,48 +432,167 @@ pub fn hex_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str> let label = take_previous_label(ui); ui.label(egui::RichText::new("#").size(15.0).color(t.text)); let id = id_with(ui.scope_id(), id); + hex_text(ui, id, hex, |ui, buf, editing| { + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(5) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + // As tall as the other fields (26 points with the margin), whatever the font's line + // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. + ui.spacing_mut().interact_size.y = 18.0; + ui.set_height(18.0); + ui.add( + egui::TextEdit::singleline(buf) + .id(id) + .frame(egui::Frame::NONE) + .align(egui::Align2::LEFT_CENTER) + .desired_width(62.0) + .char_limit(HEX_FIELD_CHARS) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + name_field(label_field(resp, label), name) + }) +} + +/// The most characters a hex field's text box holds: six digits and a `#`. +pub(crate) const HEX_FIELD_CHARS: usize = 7; + +/// What happens to a paste into a hex field. +#[derive(Debug, PartialEq, Eq)] +enum HexPaste { + /// Hex digits that leave at most six digits with the text that stays: the text box inserts them. + Insert, + /// A full color code: its digits replace the field's, wherever the caret is. + Replace(String), + /// Anything else: the field stays as it was. + Ignore, +} + +/// What a paste of `text` does to a hex field in which `kept` characters stay, not counting a typed `#` +/// that stays. `whole` says the paste goes over all of the field's text. A code has three or six digits +/// with or without `#`, and spaces and line breaks around it are ignored. Bare digits that do not go over +/// the whole text are inserted when the result has at most six digits, except that three digits are +/// inserted only when exactly three digits stay. Bare digits are not inserted when `before_hash` says the +/// caret is before a `#` that stays. Any other code replaces the field's text, and any other paste is +/// ignored. +fn hex_paste(text: &str, kept: usize, whole: bool, before_hash: bool) -> HexPaste { + let code = text.trim(); + let digits = code.strip_prefix('#').unwrap_or(code); + if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) { + HexPaste::Ignore + } else if digits == text && !whole && !before_hash && kept + digits.len() <= 6 && (digits.len() != 3 || kept == 3) { + HexPaste::Insert + } else if matches!(digits.len(), 3 | 6) { + HexPaste::Replace(digits.to_string()) + } else { + HexPaste::Ignore + } +} + +/// Takes this frame's pastes into hex field `id` from the events before its text box reads them, and +/// applies each one that replaces `buf`, with the caret after the new digits. Returns whether one did. +fn take_hex_paste(ui: &Ui, id: egui::Id, buf: &mut String) -> bool { + if !ui.memory(|m| m.has_focus(id)) { + return false; + } + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let (start, selected) = state.cursor.char_range().map_or((0, 0), |r| { + let r = r.as_sorted_char_range(); + (r.start.0, r.end.0 - r.start.0) + }); + let replaced = ui.input_mut(|i| take_hex_pastes(&mut i.events, buf, start, selected)); + if replaced { + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.len())))); + state.store(ui.ctx(), id); + } + replaced +} + +/// Removes the pastes from `events` that [`hex_paste`] does not leave to the text box, in order, and +/// applies each one that replaces `buf`. The caret is at character `start` with `selected` characters +/// selected. A replacement also drops the bare-digit pastes kept from earlier in the frame, which the text +/// box would otherwise insert into the new digits. Returns whether a paste replaced `buf`. +fn take_hex_pastes(events: &mut Vec, buf: &mut String, start: usize, mut selected: usize) -> bool { + let mut replaced = false; + let mut kept_events = Vec::with_capacity(events.len()); + for e in events.drain(..) { + let egui::Event::Paste(text) = &e else { + kept_events.push(e); + continue; + }; + let len = buf.chars().count(); + let hash = buf.starts_with('#') && start > 0; + let before_hash = buf.starts_with('#') && start == 0 && selected == 0; + match hex_paste(text, len.saturating_sub(selected + usize::from(hash)), selected > 0 && selected >= len, before_hash) { + HexPaste::Insert => kept_events.push(e), + HexPaste::Replace(digits) => { + *buf = digits; + selected = 0; + replaced = true; + kept_events.retain(|k| !matches!(k, egui::Event::Paste(_))); + } + HexPaste::Ignore => {} + } + } + *events = kept_events; + replaced +} + +/// `digits` with a three-digit code written out as six by doubling each digit. Other text is unchanged. +fn full_hex(digits: &str) -> String { + if digits.len() == 3 && digits.bytes().all(|b| b.is_ascii_hexdigit()) { + digits.chars().flat_map(|c| [c, c]).collect() + } else { + digits.to_string() + } +} + +/// The text and commit rules of a hex field `id` showing `hex`; `draw` draws its text box with the +/// text and whether the field has the focus. A click or keyboard focus selects the digits, a pasted +/// code replaces them ([`hex_paste`]), and Enter, Shift+Enter (which keeps the focus outside dialogs), +/// a press elsewhere and focus loss commit them. Returns the digits, without `#` and with a three-digit +/// code written out as six, on commit when they were typed into the field and differ from `hex`. +pub(crate) fn hex_text(ui: &mut Ui, id: egui::Id, hex: &str, draw: impl FnOnce(&mut Ui, &mut String, bool) -> Response) -> Option { let editing = ui.memory(|m| m.has_focus(id)); + // A press outside the field ends its editing in this frame, so the text typed is committed + // before the press acts, as in the numeric fields. + if editing + && let Some(at) = ui.input(|i| i.pointer.primary_pressed().then(|| i.pointer.interact_pos()).flatten()) + && ui.ctx().read_response(id).is_some_and(|r| !r.rect.contains(at)) + { + ui.memory_mut(|m| m.surrender_focus(id)); + } // Until the user types into it, the field shows `hex` as it is now, also while it has the // focus. Tab can give it the focus in the frame another field commits an edit, before the edit // changes `hex`; the text from before that edit is not committed when the focus moves on - // (#1692). + // (#1692). A field drawn before this one can take the focus on a press, so `editing` is already + // false in the frame the field reports `lost_focus`: the text typed is still taken then. Escape + // drops it. let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); + let had_focus = ui.memory(|m| m.had_focus_last_frame(id)) && !ui.input(|i| i.key_pressed(egui::Key::Escape)); + let edited = (editing || had_focus) && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; if after_shift_enter(ui, id) { select_all(ui, id, &buf); } let kept = shift_enter_keeps_focus(ui, id); - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(5) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - // As tall as the other fields (26 points with the margin), whatever the font's line - // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. - ui.spacing_mut().interact_size.y = 18.0; - ui.set_height(18.0); - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .align(egui::Align2::LEFT_CENTER) - .desired_width(62.0) - .char_limit(7) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - let resp = name_field(label_field(resp, label), name); - let commit = (resp.lost_focus() || kept) && buf != hex; + let pasted = take_hex_paste(ui, id, &mut buf); + let resp = draw(ui, &mut buf, editing); + select_all_on_focus(ui, &resp, &buf); + let digits = full_hex(buf.strip_prefix('#').unwrap_or(&buf)); + let commit = (resp.lost_focus() || kept) && !digits.eq_ignore_ascii_case(hex); + let committed = commit.then_some(digits); let focused = ui.memory(|m| m.has_focus(id)); ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); + d.insert_temp(edited_id, (edited || pasted || resp.changed()) && focused && !kept); + d.insert_temp(id, buf); }); - commit.then_some(buf) + committed } /// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`. @@ -651,3 +770,55 @@ pub fn region_texture( let color = crate::canvas::color_image(&img); ctx.load_texture(name, color, egui::TextureOptions::LINEAR) } + +#[cfg(test)] +mod hex_paste_tests { + use super::{HexPaste, hex_paste, take_hex_pastes}; + + fn paste(text: &str) -> egui::Event { + egui::Event::Paste(text.into()) + } + + /// A paste with the caret before a `#` that stays never inserts bare digits ahead of it. + #[test] + fn bare_digits_are_not_inserted_before_a_hash_that_stays() { + assert_eq!(hex_paste("c", 3, false, false), HexPaste::Insert); + assert_eq!(hex_paste("c", 3, false, true), HexPaste::Ignore); + assert_eq!(hex_paste("fff", 3, false, false), HexPaste::Insert); + assert_eq!(hex_paste("fff", 3, false, true), HexPaste::Replace("fff".into())); + } + + /// Field text, caret, selected characters, pastes, text after, and pastes left for the text box. + type Case = (&'static str, usize, usize, &'static [&'static str], &'static str, usize); + + #[test] + fn pastes_at_the_start_of_a_hash_field_and_over_it() { + let cases: [Case; 5] = [ + ("#ab", 0, 0, &["fff"], "fff", 0), + ("#ab", 0, 0, &["c"], "#ab", 0), + ("#ab", 0, 1, &["c"], "#ab", 1), + ("#ab", 0, 3, &["fff"], "fff", 0), + ("#ab", 1, 0, &["c"], "#ab", 1), + ]; + for (text, start, selected, pastes, after, left) in cases { + let mut buf = text.to_string(); + let mut events: Vec = pastes.iter().map(|p| paste(p)).collect(); + take_hex_pastes(&mut events, &mut buf, start, selected); + assert_eq!((buf.as_str(), events.len()), (after, left), "{text:?} caret {start} selected {selected} pasting {pastes:?}"); + } + } + + /// A replacing paste drops the digits kept from earlier pastes in the same frame. + #[test] + fn a_replacing_paste_drops_earlier_inserts_in_the_frame() { + let mut buf = "a".to_string(); + let mut events = vec![paste("b"), egui::Event::Text("x".into()), paste("fff")]; + assert!(take_hex_pastes(&mut events, &mut buf, 1, 0)); + assert_eq!(buf, "fff"); + assert_eq!(events.len(), 1, "only the typed text is left"); + let mut buf = "ab".to_string(); + let mut events = vec![paste("c"), paste("d")]; + assert!(!take_hex_pastes(&mut events, &mut buf, 2, 0)); + assert_eq!((buf.as_str(), events.len()), ("ab", 2), "inserts alone stay for the text box"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bb928bea3..6ae62e897 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1112,6 +1112,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged. - **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them. - **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged. + - **Pasting into hex color fields:** in the hex fields of the Color Picker, Swatch Options, Recolor Artwork, the Color panel, Document Setup, Duotone, Color Themes and Perspective Grid, a click or keyboard focus selects the digits. A pasted code of six digits, with or without `#`, replaces them wherever the caret is. A pasted code of three digits also replaces them, unless the paste holds only those digits and exactly three digits stay; those digits then edit the text in place. A press on another field commits the text as Tab and Enter do. As VectorCraft's own rule, a paste that is not a color leaves the field as it was. Tests: `a_hex_code_pasted_into_the_hex_field_replaces_its_digits` in `crates/ui-egui/src/dialogs/tests.rs`, `define_grid_takes_a_three_digit_color` in `crates/ui-egui/src/dialogs/tests_perspective.rs` and `a_paste_into_a_hex_field_replaces_or_edits_its_digits` in `crates/ui-egui/src/tests/numfields.rs`. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13.