From 825bb708df4a88a2533d33e78f4618ded8d6f97f Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Thu, 15 Oct 2026 13:51:57 -0300 Subject: [PATCH] M7: fields select their text on focus A click or Tab into a one-line field selects all of its text, so typing replaces the value, as in Figma: hex and colour fields, inline fields (a fill's hex, variable values, renames), names, the font size field, search boxes and dialog fields (widgets::select_all_on_focus). A drag from the press still selects only what it covers; multi-line fields keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard: egui dropped the focus of the editor created by that click, so it took a second click. --- crates/ui-egui/src/assets_panel.rs | 1 + crates/ui-egui/src/colorpicker.rs | 1 + crates/ui-egui/src/dialogs.rs | 10 +- crates/ui-egui/src/layers.rs | 1 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live.rs | 3 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/select_all_tests.rs | 136 +++++++++++++++++++++++++ crates/ui-egui/src/shortcuts_panel.rs | 3 +- crates/ui-egui/src/style_picker.rs | 2 + crates/ui-egui/src/typography.rs | 2 + crates/ui-egui/src/variable_ui.rs | 1 + crates/ui-egui/src/variables_panel.rs | 3 +- crates/ui-egui/src/versions.rs | 1 + crates/ui-egui/src/widgets.rs | 58 +++++++++-- docs/ui-parity.md | 4 +- 16 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/select_all_tests.rs diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 5ea19375..80e5c176 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -314,6 +314,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { .hint_text(tr("Search assets")) .desired_width(te_rect.width()), ); + crate::widgets::select_all_on_focus(ui, &r, &filter); if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index e55130c5..7d4be49d 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -1030,6 +1030,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m ui.horizontal(|ui| { ui.add_space(16.0); let r = ui.add(egui::TextEdit::singleline(name).font(regular(11.0)).desired_width(136.0)); + crate::widgets::select_all_on_focus(ui, &r, name); if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) { r.request_focus(); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee636..2a8c68e3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -253,8 +253,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) { .resizable(false) .show(ctx, |ui| { ui.set_width(280.0); - ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0)); - ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0)); + let r = ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0)); + crate::widgets::select_all_on_focus(ui, &r, &find); + let r = ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0)); + crate::widgets::select_all_on_focus(ui, &r, &replace); ui.horizontal(|ui| { if ui.button(tr("Find")).clicked() { action = Some(false); @@ -356,11 +358,13 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { ui.set_width(320.0); ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); ui.add_space(6.0); - ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0)); + let r = ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0)); + crate::widgets::select_all_on_focus(ui, &r, &b.find); if crate::widgets::checkbox(ui, b.regex, tr("Regular expression")) { b.regex = !b.regex; } let to = ui.add(egui::TextEdit::singleline(&mut b.to).hint_text(tr("Rename to")).desired_width(300.0)); + crate::widgets::select_all_on_focus(ui, &to, &b.to); ui.horizontal(|ui| { for (label, code) in [(tr("Current name"), "$&"), (tr("Number ↑"), "$n"), (tr("Number ↓"), "$N")] { if ui.button(label).clicked() { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 927e3ae4..c4299c2b 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -579,6 +579,7 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { .vertical_align(egui::Align::Center) .desired_width(te_rect.width()), ); + crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter); if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..16422869 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -147,6 +147,8 @@ mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] +mod select_all_tests; +#[cfg(test)] mod shortcut_keys_tests; #[cfg(test)] mod shortcuts_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 46687a68..f1f30047 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -987,6 +987,7 @@ fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { .id(egui::Id::new("live_report_reason")), ); a11y::text(&resp, tr("Report"), &reason); + crate::widgets::select_all_on_focus(ui, &resp, &reason); if std::mem::take(&mut app.ui.live.focus_report) { resp.request_focus(); } @@ -1148,6 +1149,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { ); // The address may carry the server key: it isn't read out. a11y::text(&resp, tr("Server address"), ""); + crate::widgets::select_all_on_focus(ui, &resp, &form.server); if focus { resp.request_focus(); } @@ -1177,6 +1179,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { widgets::label(ui, tr("File name"), t.text_secondary); let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name"))); a11y::text(&nresp, tr("File name"), &form.name); + crate::widgets::select_all_on_focus(ui, &nresp, &form.name); go |= nresp.lost_focus() && enter(ui) && (keyed || open_server); ui.add_space(4.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b7e0552a..8e4ebd2d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -627,6 +627,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), ); r.request_focus(); + crate::widgets::select_all_on_focus(ui, &r, &q); ui.add_space(4.0); let ql = q.to_lowercase(); // Labels match in the UI language and in English. diff --git a/crates/ui-egui/src/select_all_tests.rs b/crates/ui-egui/src/select_all_tests.rs new file mode 100644 index 00000000..f3606c1c --- /dev/null +++ b/crates/ui-egui/src/select_all_tests.rs @@ -0,0 +1,136 @@ +//! A click (or Tab) into a one-line field selects all of its text, so typing replaces the value: +//! the hex fields, inline fields (a fill's hex, renames), search boxes; a drag from the press +//! selects only what it covers. + +use egui::{Event, Id, Key, Modifiers, Pos2, RawInput, Rect, Ui, UiBuilder, pos2, vec2}; + +use crate::widgets; + +const FIELD: Rect = Rect { min: pos2(20.0, 20.0), max: pos2(140.0, 44.0) }; + +fn frame(ctx: &egui::Context, events: Vec, f: &mut impl FnMut(&mut Ui)) { + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), events, ..Default::default() }; + ctx.run_ui(input, |ui| { + let mut child = ui.new_child(UiBuilder::new().max_rect(FIELD)); + f(&mut child); + }) + .textures_delta + .clear(); +} + +fn button(p: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +/// Moves to `at`, presses, (drags to `to`,) releases, then a few quiet frames. +fn click(ctx: &egui::Context, at: Pos2, to: Option, f: &mut impl FnMut(&mut Ui)) { + frame(ctx, vec![], f); + frame(ctx, vec![Event::PointerMoved(at)], f); + frame(ctx, vec![button(at, true)], f); + let end = to.unwrap_or(at); + if let Some(to) = to { + for k in 1..=4 { + frame(ctx, vec![Event::PointerMoved(at + (to - at) * (k as f32 / 4.0))], f); + } + } + frame(ctx, vec![button(end, false)], f); + for _ in 0..3 { + frame(ctx, vec![], f); + } +} + +fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> { + let r = egui::TextEdit::load_state(ctx, te)?.cursor.char_range()?; + let (a, b): (usize, usize) = (r.primary.index.into(), r.secondary.index.into()); + Some((a.min(b), a.max(b))) +} + +#[test] +fn a_click_into_a_hex_field_selects_it_all() { + let ctx = egui::Context::default(); + let id = Id::new("hex"); + let mut f = |ui: &mut Ui| { + widgets::text_field(ui, id, "D9D9D9", 120.0, ""); + }; + click(&ctx, pos2(60.0, 32.0), None, &mut f); + assert!(ctx.memory(|m| m.has_focus(id.with("te")))); + assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); +} + +#[test] +fn a_click_into_an_inline_field_selects_it_all() { + // A fill's hex in the Design panel, a variable's value, a rename. + let ctx = egui::Context::default(); + let id = Id::new("fill_hex"); + let mut f = |ui: &mut Ui| { + widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0)); + }; + click(&ctx, pos2(70.0, 32.0), None, &mut f); + assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); + // Typing replaces it. + frame(&ctx, vec![Event::Text("red".into())], &mut f); + let mut out = None; + let mut g = |ui: &mut Ui| out = widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0)); + frame(&ctx, vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], &mut g); + assert_eq!(out.as_deref(), Some("red")); +} + +#[test] +fn a_click_into_a_search_box_selects_its_query() { + let ctx = egui::Context::default(); + let id = Id::new("search"); + let mut q = String::from("button"); + let mut f = |ui: &mut Ui| { + widgets::text_input(ui, id, &mut q, 120.0, "Search"); + }; + click(&ctx, pos2(60.0, 32.0), None, &mut f); + assert_eq!(selection(&ctx, id), Some((0, 6))); +} + +#[test] +fn tab_into_a_field_selects_it_all() { + let ctx = egui::Context::default(); + let id = Id::new("tabbed"); + let mut q = String::from("Frame 1"); + let mut f = |ui: &mut Ui| { + let r = widgets::text_input(ui, id, &mut q, 120.0, ""); + if !r.has_focus() && ui.input(|i| i.key_pressed(Key::Tab)) { + r.request_focus(); + } + }; + frame(&ctx, vec![], &mut f); + frame(&ctx, vec![Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], &mut f); + for _ in 0..3 { + frame(&ctx, vec![], &mut f); + } + assert_eq!(selection(&ctx, id), Some((0, 7))); +} + +#[test] +fn a_drag_selects_only_what_it_covers() { + let ctx = egui::Context::default(); + let id = Id::new("dragged"); + let mut q = String::from("a long search query"); + let mut f = |ui: &mut Ui| { + widgets::text_input(ui, id, &mut q, 120.0, ""); + }; + click(&ctx, pos2(30.0, 32.0), Some(pos2(70.0, 32.0)), &mut f); + let (a, b) = selection(&ctx, id).unwrap_or_default(); + assert!(b > a, "something is selected: {a}..{b}"); + assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}"); +} + +#[test] +fn a_field_left_mid_edit_selects_all_on_the_next_click() { + let ctx = egui::Context::default(); + let id = Id::new("fill_hex"); + let mut f = |ui: &mut Ui| { + widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0)); + }; + click(&ctx, pos2(70.0, 32.0), None, &mut f); + // The panel goes away mid-edit (another layer selected) and a click lands elsewhere. + click(&ctx, pos2(300.0, 200.0), None, &mut |_: &mut Ui| {}); + click(&ctx, pos2(70.0, 32.0), None, &mut f); + assert!(ctx.memory(|m| m.has_focus(id.with("te")))); + assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); +} diff --git a/crates/ui-egui/src/shortcuts_panel.rs b/crates/ui-egui/src/shortcuts_panel.rs index 05ac20ef..5ff960fd 100644 --- a/crates/ui-egui/src/shortcuts_panel.rs +++ b/crates/ui-egui/src/shortcuts_panel.rs @@ -107,7 +107,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.horizontal(|ui| { crate::dialogs::tab_strip(ui, &t, &Group::ALL, |g| tr(g.label()), &mut app.ui.shortcuts_tab); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut app.ui.shortcuts_query).hint_text(tr("Search shortcuts")).desired_width(180.0)); + let r = ui.add(egui::TextEdit::singleline(&mut app.ui.shortcuts_query).hint_text(tr("Search shortcuts")).desired_width(180.0)); + crate::widgets::select_all_on_focus(ui, &r, &app.ui.shortcuts_query); }); }); ui.add_space(10.0); diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 6f0cb2cd..a43e54dd 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -540,6 +540,7 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut } ui.add_space(4.0); let r = ui.add(egui::TextEdit::singleline(&mut p.query).hint_text(tr("Search")).font(regular(11.0)).desired_width(W)); + crate::widgets::select_all_on_focus(ui, &r, &p.query); if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) { r.request_focus(); } @@ -799,6 +800,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { egui::TextEdit::store_state(ui.ctx(), r.id, state); e.focus = false; } + crate::widgets::select_all_on_focus(ui, &r, &e.name); ui.add_space(6.0); caption(ui, tr("Description")); ui.add(egui::TextEdit::multiline(&mut e.description).font(regular(11.0)).desired_width(W).desired_rows(2)); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 56854f27..c95d062d 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -996,6 +996,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti .desired_width(text_rect.width() - 6.0), ); crate::a11y::own_focus(&r); + crate::widgets::select_all_on_focus(ui, &r, &text); // The Enter that started the edit (from the keyboard) doesn't end it. let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter)); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); @@ -1089,6 +1090,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) q.clear(); } let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); + crate::widgets::select_all_on_focus(ui, &search, &q); if just_opened { search.request_focus(); } diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 48fecb73..04507c0c 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -397,6 +397,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { }); ui.add_space(4.0); let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tr("Search")).font(regular(11.0)).desired_width(224.0)); + crate::widgets::select_all_on_focus(ui, &r, &query); if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) { r.request_focus(); } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 20137482..4c4923da 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -523,13 +523,14 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { let mut child = ui.new_child( UiBuilder::new().max_rect(Rect::from_min_max(pos2(field.min.x + 24.0, field.min.y + 4.0), pos2(field.max.x - 4.0, field.max.y - 4.0))), ); - child.add( + let r = child.add( egui::TextEdit::singleline(&mut st.query) .frame(egui::Frame::NONE) .hint_text(tr("Search")) .font(regular(11.0)) .vertical_align(egui::Align::Center), ); + crate::widgets::select_all_on_focus(ui, &r, &st.query); let note = Rect::from_min_max(pos2(field.max.x + 16.0, sr.min.y + 8.0), pos2(sr.max.x - 16.0, sr.max.y - 8.0)); let mut bar = ui.new_child(UiBuilder::new().max_rect(note).layout(egui::Layout::right_to_left(egui::Align::Center))); let text = match (view.library, coll.extends.as_deref().and_then(|p| view.vars.collection(p))) { diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index 12b48448..2d0f9ae2 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -330,6 +330,7 @@ fn form(ui: &mut Ui, f: &mut Form, submit: &str, indent: f32) -> Option ui.add(egui::TextEdit::singleline(&mut f.name).hint_text(hint).font(regular(11.0)).frame(egui::Frame::NONE).desired_width(w)) }); crate::a11y::text(&title, hint, &f.name); + crate::widgets::select_all_on_focus(ui, &title, &f.name); if std::mem::take(&mut f.focus) { title.request_focus(); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index db696a1e..099e167c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -494,6 +494,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str if started { editing = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(id.with("te"))); + select_all_next(ui, id.with("te")); } let mut out = None; match editing { @@ -527,14 +528,16 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str) -> Response { let t = Tokens::get(ui.ctx()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0)))); - child.add( + let r = child.add( egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(egui::RichText::new(hint).font(regular(11.0)).color(t.text_tertiary)) .desired_width(rect.width() - 16.0), - ) + ); + select_all_on_focus(ui, &r, text); + r } /// A text field that is typed into in place: `text` changes as it is typed (the caller decides @@ -562,6 +565,48 @@ fn keep_focus(r: &Response, started: bool) { } } +/// Selects all of a one-line editor's `text` when it gains the keyboard (a click, Tab or a focus +/// request), so what is typed replaces the value, as in Figma's fields. Call it after adding the +/// editor (`r` is its response). egui puts the caret where the press landed, so the selection is +/// laid again until the button is released; a drag from the press selects what it covers instead. +pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) { + let pending = select_all_id(r.id); + if r.gained_focus() { + ui.data_mut(|d| d.insert_temp(pending, true)); + } + if !ui.data(|d| d.get_temp::(pending)).unwrap_or(false) { + return; + } + // Not focused yet (the click that starts an edit can land before the editor exists): wait. + if !r.has_focus() { + return; + } + let (down, dragged) = ui.input(|i| (i.pointer.any_down(), i.pointer.total_drag_delta().is_some_and(|d| d.length() > 6.0))); + if down && dragged { + ui.data_mut(|d| d.remove::(pending)); + return; + } + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), r.id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), r.id); + ui.ctx().request_repaint(); + } + if !down { + ui.data_mut(|d| d.remove::(pending)); + } +} + +/// Has [`select_all_on_focus`] select all of editor `te_id` from now on (an edit that starts +/// with the editor already focused, which then gains no focus). +fn select_all_next(ui: &Ui, te_id: Id) { + ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true)); +} + +fn select_all_id(te_id: Id) -> Id { + te_id.with("select_all_on_focus") +} + /// What a dropdown shows before its label. #[derive(Clone, Copy)] pub enum DropLead { @@ -963,11 +1008,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu if started { buf = Some(value.to_string()); ui.memory_mut(|m| m.request_focus(te_id)); - // A rename starts with the whole name selected. - let mut state = egui::TextEdit::load_state(ui.ctx(), te_id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(value.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), te_id); + select_all_next(ui, te_id); } match buf { Some(mut text) => { @@ -976,6 +1017,9 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width()); a11y::own_focus(&r); + keep_focus(&r, started); + // A rename or a hex edit starts with the whole text selected. + select_all_on_focus(ui, &r, &text); // The Enter that started the edit (from the keyboard) doesn't end it. let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter)); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cdc94e3..7173bcd7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -26,6 +26,7 @@ interaction feel not yet compared. | Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | +| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click (`widgets::select_all_on_focus`, `select_all_tests`) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | ## Layers and Assets panels @@ -164,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | | 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) | | 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu | | 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) |