Fix Circular Pattern dialog showing two fields active at once (#10066) (#10078)

* claim: #10066

* Keep one dialog field active at a time (#10066)

Pick fields drew their accent ring from the dialog's active-input index
while text editors drew theirs from egui keyboard focus, so a Circular
Pattern dialog could show Axis and Quantity active together. A pick
field now drops its ring while a text editor holds the keyboard, and
clicking a pick field (or its clear button) takes the keyboard back.
Selection routing (d.active) is unchanged.

* Format editor_has_focus onto one line

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
This commit is contained in:
Dahak Builds
2026-10-31 14:01:09 +00:00
committed by GitHub
parent 2b1ca94e2d
commit 9a44641970
5 changed files with 60 additions and 2 deletions
+14 -1
View File
@@ -2644,6 +2644,14 @@ fn stored_revolve_axis(kind: &Kind) -> Option<&Value> {
}
}
/// Give the keyboard back from whatever text editor holds it, so the pick field clicked into
/// becomes the one field that looks active (#10066).
fn surrender_keyboard_focus(ui: &egui::Ui) {
if let Some(id) = ui.ctx().memory(|memory| memory.focused()) {
ui.ctx().memory_mut(|memory| memory.surrender_focus(id));
}
}
pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, names: crate::prefs::OriginPlaneNames, range: std::ops::Range<usize>) {
// Edit Text shows its Path input among its own rows.
if matches!(d.kind, Kind::Text(_)) {
@@ -2653,12 +2661,15 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
let mut clear: Option<usize> = None;
let mut activate: Option<usize> = None;
let mut remove = None;
// Only one field may look active at a time (#10066): while a text editor holds the keyboard,
// no pick field draws its accent ring, and clicking a pick field takes the keyboard back.
let text_focus = crate::ui_kit::editor_has_focus(ui.ctx());
let shell_count = d.shell_faces(s).map(|faces| faces.len());
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
let stored_reference = feature_pattern_reference_key(d, i).and_then(|key| d.extra.get(key)).filter(|_| inp.items.is_empty());
form_row(ui, inp.label, |ui| {
ui.horizontal(|ui| {
let active = i == d.active;
let active = i == d.active && !text_focus;
// A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked.
let custom_axis = i == 1 && inp.items.is_empty() && stored_revolve_axis(&d.kind).is_some();
let filled = !inp.items.is_empty() || custom_axis || stored_reference.is_some();
@@ -2709,6 +2720,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
};
if chip.on_hover_text(tooltip).clicked() {
activate = Some(i);
surrender_keyboard_focus(ui);
}
if !inp.items.is_empty() || stored_reference.is_some() {
let rect = egui::Rect::from_center_size(
@@ -2719,6 +2731,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
crate::scenario::publish_handle(&format!("dialog:clear:{}", inp.label), button.rect.center());
if button.clicked() {
clear = Some(i);
surrender_keyboard_focus(ui);
}
}
});
+34
View File
@@ -1173,3 +1173,37 @@ fn ok_on_a_rolled_back_pattern_editor_says_it_is_not_built() {
assert!(!error, "{text}");
assert!(text.starts_with("Pattern changed, but it is after the history marker"), "{text}");
}
/// The Circular Pattern dialog must show only one field active at a time (#10066): the pick
/// field's accent ring yields while a text field holds the keyboard, and clicking a pick field
/// takes the keyboard back.
#[test]
fn circular_pattern_dialog_shows_one_active_field_at_a_time() {
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 10})).unwrap();
let mut d = Dialog::for_command(&h.app, "solid.pattern.circular").unwrap();
d.inputs[1].items = vec![Sel::Axis { name: "Z".into() }];
h.app.dialog = Some(d);
h.settle();
let active = |label: &str| crate::scenario::count_of(&format!("dialog:selection:{label}:active")) == Some(1.0);
let keyboard = |h: &Harness| h.ctx.memory(|m| m.focused()).is_some();
let click_field = |h: &mut Harness, rect: &str| {
let at = crate::scenario::rect_of(rect).unwrap_or_else(|| panic!("{rect} is not drawn")).center();
h.step(&json!({"click": [at.x, at.y]})).unwrap();
h.frames(2);
};
// A fresh dialog: the Objects pick field is the one active field, no text field has the keyboard.
assert!(!keyboard(&h), "nothing holds the keyboard when the dialog opens");
assert_eq!((active("Objects"), active("Axis")), (true, false), "only Objects looks active");
// Clicking Quantity: the text field takes the keyboard and no pick field keeps its ring.
h.step(&json!({"click": {"handle": "dialog:Quantity"}})).unwrap();
h.frames(2);
assert!(keyboard(&h), "Quantity holds the keyboard");
assert_eq!((active("Objects"), active("Axis")), (false, false), "only the typed field looks active");
// Clicking the Axis pick field: it becomes the one active field and takes the keyboard back.
click_field(&mut h, "dialog:field:Axis");
assert!(!keyboard(&h), "the pick field takes the keyboard from Quantity");
assert_eq!((active("Objects"), active("Axis")), (false, true), "only Axis looks active");
assert_eq!(h.app.dialog.as_ref().unwrap().active, 1, "picks still go to Axis");
}
+1 -1
View File
@@ -274,7 +274,7 @@ fn only_the_active_selection_field_looks_active() {
let filled = border(&h, "Profile");
click(&mut h, "dialog:Angle");
assert_eq!(h.app.dialog.as_ref().unwrap().active, 1, "picks still go to Axis while Angle is typed into");
assert_eq!(border(&h, "Axis"), t.form_primary);
assert_ne!(border(&h, "Axis"), t.form_primary, "only the field with the keyboard looks active (#10066)");
click(&mut h, "dialog:clear:Axis");
click(&mut h, "dialog:field:Profile");
assert_eq!(crate::scenario::count_of("dialog:selection:Axis:filled"), Some(0.0));
+4
View File
@@ -265,6 +265,9 @@ fn editor_reserving(ui: &mut Ui, value: &mut String, hint: &str, completion: boo
edit = edit.margin(egui::Margin { left: inset, right: inset.saturating_add(reserve.round() as i8), top: 0, bottom: 0 });
}
let mut response = ui.add_sized(vec2(width, row_height()), edit);
if response.has_focus() {
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("sc_focused_editor"), response.id));
}
super::text_menu::menu(ui, &mut response, value, super::text_menu::Field::LINE);
if completion && ui.is_enabled() {
ui.ctx().data_mut(|data| {
@@ -453,6 +456,7 @@ pub(crate) fn selection_field(ui: &mut Ui, text: &str, options: Selection) -> Re
publish(ui, &response, egui::WidgetType::Button);
scenario::publish_rect(&format!("dialog:field:{}", label(ui)), rect);
scenario::publish_count(&format!("dialog:selection:{}:filled", label(ui)), f64::from(filled));
scenario::publish_count(&format!("dialog:selection:{}:active", label(ui)), f64::from(active));
response
}
+7
View File
@@ -15,6 +15,13 @@ pub(crate) fn last_value_field(ctx: &egui::Context) -> Option<egui::Id> {
ctx.data(|data| data.get_temp::<Vec<(egui::Id, usize)>>(egui::Id::new("sc_value_fields"))).and_then(|fields| fields.last().map(|(id, _)| *id))
}
/// True while a text editor drawn by the kit holds the keyboard. A dialog uses this to keep at
/// most one field looking active: its pick fields drop their accent ring while a text field
/// has the keyboard (#10066).
pub(crate) fn editor_has_focus(ctx: &egui::Context) -> bool {
ctx.data(|data| data.get_temp::<egui::Id>(egui::Id::new("sc_focused_editor"))).is_some_and(|id| ctx.memory(|memory| memory.focused() == Some(id)))
}
/// Only a value box drawn this pass can receive the keyboard after a viewport pick.
pub(crate) fn canvas_value_focus(ctx: &egui::Context) -> Option<(egui::Id, usize)> {
ctx.data(|data| data.get_temp::<(u64, egui::Id, usize)>(egui::Id::new("sc_canvas_value_focus")))