Merge pull request #6151 from dotCooCoo/fix/tab-one-press

Fields: Tab moves from a field to the next past its spinner and preset chevron
This commit is contained in:
BFlat
2026-11-08 14:15:40 +08:00
committed by GitHub
7 changed files with 308 additions and 27 deletions
+10 -4
View File
@@ -297,10 +297,10 @@ fn the_link_is_a_preference_agents_can_read_and_set() {
assert_eq!(size(&app), (200.0, 100.0));
}
/// One Tab moves from a field to the next field: a field's label (its scrub zone) is not a tab
/// stop of its own, and the W/H link between W and H, which is still focusable, comes after the
/// fields. In the Transform panel and the Properties panel the fields run X, W, Y, H (a grid, two
/// to a row); in the Control bar X, Y, W, H.
/// One Tab moves from a field to the next field: a field's label (its scrub zone), spinner and
/// preset chevron are not tab stops of their own, and the W/H link between W and H, which is
/// still focusable, comes after the fields. In the Transform panel and the Properties panel the
/// fields run X, W, Y, H (a grid, two to a row); in the Control bar X, Y, W, H.
#[test]
fn one_tab_moves_from_a_field_to_the_next() {
// From X (the selection's centre, 60 pt) the next field is W in the panels and Y in the bar.
@@ -328,6 +328,12 @@ fn one_tab_moves_from_a_field_to_the_next() {
tab_then_type(&mut app, crate::chrome::control_bar, "400 pt", "200");
let r = app.session.doc().unwrap().doc.artboards[0].rect;
assert_eq!((r.width().round(), r.height().round()), (400.0, 200.0), "Artboard Control bar: Tab went from W to H");
// The Transform panel's Rotate and Shear fields each have a spinner and presets: Tab goes from
// Rotate straight to Shear, past Rotate's preset chevron and Shear's spinner.
let mut app = rect_app();
tab_then_type(&mut app, transform::show, "0°", "30");
let shear = app.session.active().map(|st| st.doc.bounds_of(&st.selection.objects, false).map(|b| (b.width().round(), b.height().round())));
assert_eq!(shear, Some(Some((129.0, 50.0))), "Transform panel: Tab went from Rotate to Shear");
// Shift+Tab goes back one field, one press too: from W to X in the panels.
for (name, draw) in [("Transform panel", transform::show as Draw), ("Properties", properties::transform_section as Draw)] {
let mut app = rect_app();
+175
View File
@@ -418,6 +418,181 @@ fn key(key: Key, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
}
/// What [`preset_row`]'s fields committed or picked in a frame: the two numbers, the text and the
/// pick of the field with an Auto entry.
type PresetPicks = (Option<f64>, Option<f64>, Option<String>, Option<widgets::SpinPick>);
const NO_PICK: PresetPicks = (None, None, None, None);
thread_local! {
/// Whether the last [`preset_row`] frame left an Enter press for a dialog drawn after it.
static ENTER_LEFT: Cell<bool> = const { Cell::new(false) };
}
/// One frame in `ctx` with `events` of a row of two numeric fields with a spinner and presets
/// (Stroke Weight, and a font size that is none of its presets), a text field with presets (Export
/// for Screens' scale) and a numeric field whose list starts with Auto, which is on (Leading).
fn preset_row(ctx: &egui::Context, time: &Cell<f64>, events: Vec<Event>) -> PresetPicks {
time.set(time.get() + 0.05);
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0)));
let mut got = NO_PICK;
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| {
ui.horizontal(|ui| {
got.0 = widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0, 4.0]);
got.1 = widgets::spin_field(ui, "size", Some(13.0), Unit::Points, 100.0, 1.0, 0.0, &[9.0, 12.0]);
got.2 = widgets::text_presets(ui, "scale", "1x", &["1x", "2x"], 100.0);
got.3 = widgets::spin_field_auto(ui, "leading", Some(14.0), Some(true), Unit::Points, 100.0, 1.0, 0.0, &[12.0, 14.0]);
});
ENTER_LEFT.set(crate::shortcuts::pressed_anew(ui.ctx(), Key::Enter));
});
out.textures_delta.clear();
got
}
/// Tab goes from one field to the next in one press: it does not stop on a field's spinner or on
/// the chevron that opens its presets, and Shift+Tab goes back the same way.
#[test]
fn tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
preset_row(&ctx, &time, vec![]);
preset_row(&ctx, &time, vec![]);
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 8, |events| {
preset_row(&ctx, &time, events);
});
let widths: Vec<f32> = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect();
assert_eq!(widths.len(), 8, "{stops:?}");
assert!(widths.iter().all(|w| *w > 40.0), "only the fields are Tab stops, not the spinners or chevrons: {widths:?}");
assert_eq!(stops[..4], stops[4..], "four fields, then back to the first: {widths:?}");
let mut back = vec![];
for _ in 0..2 {
preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::SHIFT)]);
preset_row(&ctx, &time, vec![]);
back.extend(ctx.memory(|m| m.focused()));
}
assert_eq!(back, [stops[2], stops[1]], "Shift+Tab goes back field by field");
}
/// The Transparency row's opacity slider chevron is not a Tab stop either: Tab goes from the
/// Opacity field straight to the next field.
#[test]
fn tab_goes_from_the_opacity_field_past_its_slider_chevron() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
let frame = |events: Vec<Event>| {
time.set(time.get() + 0.05);
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0)));
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| {
ui.horizontal(|ui| {
widgets::opacity_blend(ui, "transparency", Some(0.5), None, true);
widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[]);
});
});
out.textures_delta.clear();
};
frame(vec![]);
frame(vec![]);
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 6, frame);
let widths: Vec<f32> = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect();
assert_eq!(widths.len(), 6, "{stops:?}");
assert!(widths.iter().all(|w| *w > 20.0), "the 18 pt slider chevron is not a Tab stop: {widths:?}");
assert_eq!(stops[..3], stops[3..], "blend mode, Opacity and the next field, then back: {widths:?}");
}
/// Gives the keyboard focus to the field that `tabs` presses of Tab reach in [`preset_row`], with
/// every list closed, and returns that field.
fn focus_preset_field(ctx: &egui::Context, time: &Cell<f64>, tabs: usize) -> Option<egui::Id> {
egui::Popup::close_all(ctx);
ctx.memory_mut(|m| m.surrender_focus(m.focused().unwrap_or(egui::Id::NULL)));
preset_row(ctx, time, vec![]);
for _ in 0..tabs {
preset_row(ctx, time, vec![key(Key::Tab, Modifiers::NONE)]);
}
preset_row(ctx, time, vec![]);
ctx.memory(|m| m.focused())
}
/// The presets stay in reach of the keyboard: Alt+↓ in a field opens its preset list with the
/// keyboard focus on the chosen preset (the first entry when none is chosen, Auto when it is on),
/// Tab goes on to the next one and Enter picks it, without reaching a dialog's OK. The field then
/// has the keyboard focus again.
#[test]
fn alt_down_opens_a_fields_presets_for_the_keyboard() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
let alt_down = || key(Key::ArrowDown, Modifiers::ALT);
// (Tabs to the field, the pick after Tab and Enter in its list.)
let cases = [
(1, (Some(4.0), None, None, None)),
(2, (None, Some(12.0), None, None)),
(3, (None, None, Some("2x".to_string()), None)),
(4, (None, None, None, Some(widgets::SpinPick::Value(12.0)))),
];
for (tabs, picked) in cases {
let field = focus_preset_field(&ctx, &time, tabs);
assert!(field.is_some(), "{tabs}");
assert_eq!(preset_row(&ctx, &time, vec![alt_down()]), NO_PICK, "{tabs}");
preset_row(&ctx, &time, vec![]);
let entry = ctx.memory(|m| m.focused());
assert!(entry.is_some() && entry != field, "{tabs}: the list takes the focus");
let layer = ctx.read_response(entry.unwrap_or(egui::Id::NULL)).map(|r| r.layer_id.order);
assert_eq!(layer, Some(egui::Order::Foreground), "{tabs}: on an entry of the list");
preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::NONE)]);
preset_row(&ctx, &time, vec![]);
let mut got = preset_row(&ctx, &time, vec![enter()]);
assert!(!ENTER_LEFT.get(), "{tabs}: the list takes the Enter, so a dialog's OK does not");
if got == NO_PICK {
got = preset_row(&ctx, &time, vec![]);
}
assert_eq!(got, picked, "{tabs}: Enter picks the next preset");
preset_row(&ctx, &time, vec![]);
preset_row(&ctx, &time, vec![]);
assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed");
assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again");
}
}
/// Escape closes a preset list that Alt+↓ opened without picking anything, and the field has the
/// keyboard focus again.
#[test]
fn escape_closes_a_fields_presets_and_returns_to_the_field() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
for tabs in [1, 3] {
let field = focus_preset_field(&ctx, &time, tabs);
preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::ALT)]);
preset_row(&ctx, &time, vec![]);
assert!(egui::Popup::is_any_open(&ctx), "{tabs}: Alt+↓ opened the list");
let mut picks = vec![preset_row(&ctx, &time, vec![key(Key::Escape, Modifiers::NONE)])];
picks.push(preset_row(&ctx, &time, vec![]));
picks.push(preset_row(&ctx, &time, vec![]));
assert!(picks.iter().all(|p| *p == NO_PICK), "{tabs}: nothing picked: {picks:?}");
assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed");
assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again");
}
}
/// Only Alt+↓ with no other modifier opens the presets: Shift+Alt+↓ in a field with presets
/// steps it down by ten, as before, and leaves the list closed.
#[test]
fn shift_alt_down_still_steps_a_field_with_presets() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let time = Cell::new(0.0);
let field = focus_preset_field(&ctx, &time, 2);
let mut got = preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::SHIFT | Modifiers::ALT)]);
if got == NO_PICK {
got = preset_row(&ctx, &time, vec![]);
}
assert_eq!(got.1, Some(3.0), "13 pt steps down by ten");
assert!(!egui::Popup::is_any_open(&ctx), "the list stays closed");
assert_eq!(ctx.memory(|m| m.focused()), field);
}
#[test]
fn math_before_the_unit_suffix_evaluates_in_every_unit() {
assert_eq!(Unit::Pixels.parse("1080/2 px"), Some(540.0));
+3 -1
View File
@@ -71,7 +71,9 @@ pub fn opacity_blend(
edit = Some(TransparencyEdit::Opacity(o, Live::Released));
}
});
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() });
// Like a field's preset chevron, the slider's chevron is not a Tab stop: the field takes
// the same value from the keyboard, and Tab goes on to the next field.
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::CLICK } else { Sense::hover() });
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity")));
+30 -16
View File
@@ -1,5 +1,7 @@
use super::labels::{PreviousLabel, label_field, name_field, take_previous_label};
use super::panel_chrome::chevron_cell;
use super::panel_chrome::{
alt_down_in_field, chevron_cell, entry_to_focus, keep_enter_in_presets, open_presets_from_keys, presets_open, return_focus_from_presets,
};
use super::text::{
after_shift_enter, focus_field, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_armed_focus, take_dialog_focus,
};
@@ -822,9 +824,10 @@ fn spin_generic(
let h = 26.0;
let enabled = ui.is_enabled();
ui.scope(|ui| {
// The spinner, the field and the chevron share their borders: one box in three parts.
// The spinner, the field and the chevron share their borders: one box in three parts. The
// spinner is not a Tab stop (↑/↓ in the field step it), so Tab goes from field to field.
ui.spacing_mut().item_spacing.x = -1.0;
let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click());
let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::CLICK);
ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input);
ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y));
@@ -855,30 +858,41 @@ fn spin_generic(
out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt)));
}
let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 };
let keyed = if presets.is_empty() { None } else { alt_down_in_field(ui, Rect::from_x_y_ranges(sr.right()..=sr.right() + fw, sr.y_range())) };
if let Some(v) = field(ui, fw, label) {
out = Some(SpinPick::Value(v.max(min)));
}
if !presets.is_empty() {
let dresp = chevron_cell(ui, h);
let keyed = open_presets_from_keys(ui, &dresp, keyed);
let was_open = presets_open(ui, &dresp);
egui::Popup::menu(&dresp).show(|ui| {
ui.set_min_width(width - 10.0);
if let Some(on) = auto
&& ui.selectable_label(on, tl!("Auto")).clicked()
{
out = Some(SpinPick::Auto);
}
if let Some(on) = optical
&& ui.selectable_label(on, optical_label.unwrap_or_default()).clicked()
{
out = Some(SpinPick::Optical);
}
let current = value.filter(|_| auto != Some(true));
for p in presets {
if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() {
out = Some(SpinPick::Value(*p));
let mut entries: Vec<(bool, String, SpinPick)> = Vec::new();
if let Some(on) = auto {
entries.push((on, tl!("Auto").to_string(), SpinPick::Auto));
}
if let Some(on) = optical {
entries.push((on, optical_label.unwrap_or_default().to_string(), SpinPick::Optical));
}
entries.extend(presets.iter().map(|p| (current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p), SpinPick::Value(*p))));
let focus = entry_to_focus(keyed, entries.iter().map(|e| e.0));
for (i, (on, text, pick)) in entries.into_iter().enumerate() {
let r = ui.selectable_label(on, text);
if focus == Some(i) {
r.request_focus();
}
if r.clicked() {
out = Some(pick);
// Enter or Space on the focused entry is a click too, which the menu
// does not close on by itself.
ui.close();
}
}
});
keep_enter_in_presets(ui, was_open);
return_focus_from_presets(ui, &dresp);
}
});
out
+70 -2
View File
@@ -117,10 +117,78 @@ pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str)
resp.on_hover_text(tip).clicked()
}
/// The chevron cell on the right of a field that opens its preset list (rounded on the right).
/// The field at `field`, before a preset chevron, when it has the keyboard focus and Alt+↓ (with
/// no other modifier) was pressed. The key is taken before the field is drawn, so that the field
/// does not step on it; the caller then opens the preset list ([`open_presets_from_keys`]).
pub(super) fn alt_down_in_field(ui: &Ui, field: Rect) -> Option<egui::Id> {
let focused = ui.memory(|m| m.focused())?;
let in_field = ui.ctx().read_response(focused).is_some_and(|r| field.expand(1.0).contains_rect(r.rect));
let alt_down = |e: &egui::Event| matches!(e, egui::Event::Key { key: egui::Key::ArrowDown, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::ALT));
let pressed = in_field
&& ui.is_enabled()
&& ui.input_mut(|i| {
let before = i.events.len();
i.events.retain(|e| !alt_down(e));
i.events.len() != before
});
pressed.then_some(focused)
}
/// Opens the preset list of `chevron` when Alt+↓ was pressed in `field` ([`alt_down_in_field`]),
/// and remembers the field so that [`return_focus_from_presets`] gives it the keyboard focus back
/// once the list closes. Returns whether it opened the list.
pub(super) fn open_presets_from_keys(ui: &Ui, chevron: &Response, field: Option<egui::Id>) -> bool {
let Some(field) = field else { return false };
let popup = egui::Popup::default_response_id(chevron);
egui::Popup::open_id(ui.ctx(), popup);
ui.data_mut(|d| d.insert_temp(popup.with("return focus"), field));
true
}
/// Whether the preset list of `chevron` is open, read before the list is drawn for
/// [`keep_enter_in_presets`].
pub(super) fn presets_open(ui: &Ui, chevron: &Response) -> bool {
egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(chevron))
}
/// Takes this frame's Enter presses after an open preset list was drawn: Enter picks the focused
/// entry and does not also reach the dialog's OK, as in [`super::dropdowns`]' combo boxes.
pub(super) fn keep_enter_in_presets(ui: &Ui, was_open: bool) {
if was_open {
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
}
/// After the preset list of `chevron` was drawn: when a list that Alt+↓ opened has closed (an
/// entry was picked, or Escape or a click outside closed it), the field gets the keyboard focus
/// back, unless the focus has moved on to a widget outside the list.
pub(super) fn return_focus_from_presets(ui: &Ui, chevron: &Response) {
let popup = egui::Popup::default_response_id(chevron);
let key = popup.with("return focus");
let Some(field) = ui.data(|d| d.get_temp::<egui::Id>(key)) else { return };
if egui::Popup::is_id_open(ui.ctx(), popup) {
return;
}
ui.data_mut(|d| d.remove::<egui::Id>(key));
// The list's entries are drawn in the popup's own layer, whose id is the popup id.
let elsewhere = ui.memory(|m| m.focused()).and_then(|id| ui.ctx().read_response(id)).is_some_and(|r| r.layer_id.id != popup);
if !elsewhere {
ui.memory_mut(|m| m.request_focus(field));
}
}
/// The entry of a preset list that gets the keyboard focus when Alt+↓ opened the list: the
/// chosen one, else the first. `None` when the list was not opened from the keys.
pub(super) fn entry_to_focus(opened_from_keys: bool, chosen: impl IntoIterator<Item = bool>) -> Option<usize> {
opened_from_keys.then(|| chosen.into_iter().position(|c| c).unwrap_or(0))
}
/// The chevron cell on the right of a field that opens its preset list (rounded on the right). It
/// is not a Tab stop: Tab goes from the field to the next field, and Alt+↓ in the field opens the
/// list ([`alt_down_in_field`]).
pub(super) fn chevron_cell(ui: &mut Ui, h: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click());
let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::CLICK);
ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input);
ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
let c = if !ui.is_enabled() {
+19 -3
View File
@@ -1,6 +1,8 @@
use super::labels::{PreviousLabel, label_field, name_field, take_previous_label};
use super::numeric::mixed_field_named;
use super::panel_chrome::chevron_cell;
use super::panel_chrome::{
alt_down_in_field, chevron_cell, entry_to_focus, keep_enter_in_presets, open_presets_from_keys, presets_open, return_focus_from_presets,
};
use super::{Rect, Response, Stroke, StrokeKind, Tokens, Ui, id_with, vec2};
/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the
@@ -380,16 +382,30 @@ pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&s
ui.scope(|ui| {
// The field and the chevron share a border.
ui.spacing_mut().item_spacing.x = -1.0;
let at = ui.cursor().min;
let keyed = alt_down_in_field(ui, Rect::from_min_size(at, vec2(width - 19.0, 26.0)));
out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1);
let resp = chevron_cell(ui, 26.0);
let keyed = open_presets_from_keys(ui, &resp, keyed);
let was_open = presets_open(ui, &resp);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(width - 10.0);
for p in presets {
if ui.selectable_label(*p == value, *p).clicked() {
let focus = entry_to_focus(keyed, presets.iter().map(|p| *p == value));
for (i, p) in presets.iter().enumerate() {
let r = ui.selectable_label(*p == value, *p);
if focus == Some(i) {
r.request_focus();
}
if r.clicked() {
out = Some((*p).to_string());
// Enter or Space on the focused entry is a click too, which the menu does not
// close on by itself.
ui.close();
}
}
});
keep_enter_in_presets(ui, was_open);
return_focus_from_presets(ui, &resp);
});
out
}
+1 -1
View File
@@ -751,7 +751,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected.
- While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`).
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`).
- Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column.
- The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown.
- The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group.