Merge pull request #7334 from dotCooCoo/fix/wrap-offset-length-field

Text Wrap: Offset is a distance in the document's units
This commit is contained in:
BFlat
2026-11-14 09:33:15 +07:00
committed by GitHub
9 changed files with 249 additions and 15 deletions
+5 -1
View File
@@ -34,8 +34,12 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// The Offset a wrap object takes, in points.
pub const TEXT_WRAP_OFFSET_RANGE: std::ops::RangeInclusive<f64> = -1000.0..=1000.0;
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
let (lo, hi) = (*TEXT_WRAP_OFFSET_RANGE.start(), *TEXT_WRAP_OFFSET_RANGE.end());
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(lo, hi), invert: bool_or(p, "invert", base.invert) }
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
+1
View File
@@ -33,6 +33,7 @@ pub use cmd::params::{check_keys, check_params};
pub use cmd::pathops::auto_simplify_level;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step};
pub use cmd::textwrap::TEXT_WRAP_OFFSET_RANGE;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use cmd::{EyedropperOptions, PathfinderOptions};
pub use journal::Journal;
+16 -1
View File
@@ -783,6 +783,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
return window(app, ctx);
}
let mut ok = false;
let mut ok_clicked = false;
let mut ok_enabled = true;
let mut cancel = false;
let mut reset = false;
@@ -843,7 +844,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some(label) = spec.ok.map(|ok| spec.ok_label.map_or(ok, |f| f(app)))
&& widgets::primary_button_enabled(ui, label, ok_enabled).clicked()
{
ok = true;
ok_clicked = true;
}
ui.add_space(8.0);
// While Alt (Option) is held, Cancel reads Reset on a dialog that has one.
@@ -878,6 +879,20 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
});
});
let rejected = ctx.data_mut(|d| d.remove_temp::<bool>(widgets::dialog_reject_commit_flag())).unwrap_or(false);
// A field that refused its text when the press on OK took its focus keeps the dialog open, as
// Enter does. Only a pointer click has such a press; the keyboard and screen readers press OK
// in one frame.
let press = egui::Id::unique("dialog-press-pass");
let (pressed, released) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.any_released()));
if pressed {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|m| m.insert_temp(press, pass));
}
let (pressed_at, refused_at) = ctx.data(|m| (m.get_temp::<u64>(press), m.get_temp::<u64>(widgets::dialog_reject_pass())));
let refused_during_click = released && matches!((refused_at, pressed_at), (Some(r), Some(p)) if r >= p);
if ok_clicked && !rejected && !refused_during_click {
ok = true;
}
if spec.ok.is_some() && ok_enabled && !after_panel && !rejected && crate::shortcuts::pressed_anew(ctx, egui::Key::Enter) {
ok = true;
}
+174
View File
@@ -128,10 +128,20 @@ fn lengths(command: &str) -> &'static [&'static str] {
"type.pathOptions" => &["spacing"],
"object.pathfinder.trap" => &["thickness"],
crate::addons::cutting::BRIDGES => &["width"],
"object.textWrap.options" => &["offset"],
_ => &[],
}
}
/// The range (points) of a distance parameter of the commands this dialog edits, where the
/// command has one: the field refuses a distance typed outside it.
pub(super) fn length_range(command: &str, key: &str) -> Option<std::ops::RangeInclusive<f64>> {
match (command, key) {
("object.textWrap.options", "offset") => Some(vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE),
_ => None,
}
}
/// The order the fields of the commands this dialog edits come in, where the order of their
/// names isn't it (the rest follow, by name).
fn order(command: &str) -> &'static [&'static str] {
@@ -505,4 +515,168 @@ mod tests {
assert_eq!(crate::panels::pattern_options::repeat_fields(&app).unwrap()["instances"], 4);
assert!(!app.session.in_interaction());
}
/// Text Wrap Options › Offset is a distance, and its range is the one `object.textWrap.options`
/// keeps an Offset to.
#[test]
fn text_wrap_offset_is_a_distance_within_the_commands_range() {
assert!(lengths("object.textWrap.options").contains(&"offset"));
assert!(!lengths("object.textWrap.options").contains(&"invert"));
let range = length_range("object.textWrap.options", "offset").expect("a range");
assert_eq!(range, vectorcraft_engine::TEXT_WRAP_OFFSET_RANGE);
assert_eq!(length_range("object.textWrap.options", "invert"), None);
assert_eq!(length_range("text.areaOptions", "inset"), None);
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("select.all", &json!({})).unwrap();
s.execute("object.textWrap.make", &json!({})).unwrap();
for (sent, end) in [(5000.0, *range.end()), (-5000.0, *range.start())] {
assert_eq!(s.execute("object.textWrap.options", &json!({ "offset": sent })).unwrap()["offset"], json!(end));
}
}
/// Replaces the text of the dialog's Offset field with `text`, leaving the field focused.
fn type_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
h.get_by_role_and_label(Role::TextInput, "Offset:").focus();
h.run_steps(2);
h.key_press_modifiers(all, egui::Key::A);
h.get_by_role_and_label(Role::TextInput, "Offset:").type_text(text);
h.run_steps(2);
}
/// Replaces the text of the dialog's Offset field with `text` and presses Enter.
fn enter_offset(h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str) {
type_offset(h, text);
h.key_press(egui::Key::Enter);
h.run_steps(3);
}
/// Text Wrap Options › Offset reads a bare number in the document's units and a typed unit as
/// typed, and OK sends points. A distance typed outside the command's range, or text that
/// doesn't read, is refused: the dialog stays open and the Offset isn't changed.
#[test]
fn text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
app.run("document.setUnits", json!({"units": "millimeters"})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("object.textWrap.make", json!({})).unwrap();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
crate::dialogs::show(app, ui.ctx());
},
app,
);
let offset = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>| {
h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap()
};
// The ends as the field shows them in millimeters (352.7778 mm is 1000.00006 pt) read
// back as the ends.
let ends = [("-1000 pt", -1000.0), ("352.7778", 1000.0), ("-352.7778", -1000.0), ("1000 pt", 1000.0)];
for (text, want) in [("10", 10.0 * 72.0 / 25.4), ("-12 pt", -12.0)].into_iter().chain(ends) {
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
h.run_steps(3);
enter_offset(&mut h, text);
assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it");
assert!((offset(&mut h) - want).abs() < 1e-9, "{text}: {} pt", offset(&mut h));
}
for text in ["1000.5 pt", "-2000", "abc"] {
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
h.run_steps(3);
enter_offset(&mut h, text);
let d = h.state().ui.dialog.as_ref().unwrap_or_else(|| panic!("{text}: the dialog stays open"));
assert_eq!(d.fields.get("offset"), Some(&json!(1000.0)), "{text}: the field keeps its value");
crate::dialogs::cancel(h.state_mut());
assert_eq!(offset(&mut h), 1000.0, "{text}: the Offset is unchanged");
}
// A click on OK with a value outside the range still in the field is refused too.
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
h.run_steps(3);
type_offset(&mut h, "2000 pt");
click_ok(&mut h);
assert!(h.state().ui.dialog.is_some(), "OK leaves the dialog open");
assert_eq!(offset(&mut h), 1000.0, "the Offset is unchanged");
// After it, a value inside the range committed with Tab and OK pressed from the keyboard
// apply.
type_offset(&mut h, "-7 pt");
h.key_press(egui::Key::Tab);
h.run_steps(2);
{
use egui_kittest::kittest::Queryable;
h.get_by_label("OK").focus();
}
h.run_steps(2);
h.key_press(egui::Key::Space);
h.run_steps(3);
assert!(h.state().ui.dialog.is_none(), "OK from the keyboard applied it");
assert_eq!(offset(&mut h), -7.0);
// One inside it applies.
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
h.run_steps(3);
type_offset(&mut h, "-5 pt");
click_ok(&mut h);
assert!(h.state().ui.dialog.is_none(), "OK applied it");
assert_eq!(offset(&mut h), -5.0);
}
/// In a document in picas, Text Wrap Options › Offset reads a bare number as the other length
/// fields in picas do: in points with Preferences › Units › Numbers Without Units Are Points on
/// (the default), in picas with it off. A typed unit is read as typed either way.
#[test]
fn text_wrap_offset_in_picas_follows_numbers_without_units_are_points() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
app.run("document.setUnits", json!({"units": "picas"})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("object.textWrap.make", json!({})).unwrap();
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
crate::theme::install_fonts(ui.ctx());
return;
}
crate::dialogs::show(app, ui.ctx());
},
app,
);
let apply = |h: &mut egui_kittest::Harness<'_, VectorcraftApp>, text: &str| {
crate::menus::invoke(h.state_mut(), "object.textWrap.options", json!({}));
h.run_steps(3);
enter_offset(h, text);
assert!(h.state().ui.dialog.is_none(), "{text}: OK applied it");
h.state_mut().session.execute("object.textWrap.options", &json!({})).unwrap()["offset"].as_f64().unwrap()
};
assert_eq!(apply(&mut h, "10"), 10.0, "on by default: a bare number is in points");
assert_eq!(apply(&mut h, "2p6"), 30.0, "picas and points as typed");
assert_eq!(apply(&mut h, "-1in"), -72.0, "a typed unit wins");
crate::widgets::set_bare_numbers_are_points(&h.ctx, false);
assert_eq!(apply(&mut h, "10"), 120.0, "off: a bare number is in picas");
assert_eq!(apply(&mut h, "-12 pt"), -12.0, "a typed unit wins");
}
/// Clicks the dialog's OK button with the pointer, the press and the release in frames of
/// their own.
fn click_ok(h: &mut egui_kittest::Harness<'_, VectorcraftApp>) {
use egui_kittest::kittest::Queryable;
let ok = h.get_by_label("OK").rect().center();
let (button, modifiers) = (egui::PointerButton::Primary, egui::Modifiers::default());
h.event(egui::Event::PointerMoved(ok));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: ok, button, pressed: true, modifiers });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: ok, button, pressed: false, modifiers });
h.run_steps(3);
}
}
+8 -1
View File
@@ -453,7 +453,14 @@ pub(super) fn param_fields(
if is_length(&k) {
// An effect's documented range keeps the field to the distance it applies.
let range = vectorcraft_effects::length_range(&d.str("__effect"), &k);
if spinners {
// A command's own range refuses a distance typed outside it.
if let Some(bounds) = super::command::length_range(&d.str("__command"), &k) {
let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0));
if let Some(x) = crate::widgets::num_field_refusing(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) {
d.fields.insert(k.clone(), json!(x));
changed = true;
}
} else if spinners {
let bounds = range.unwrap_or(if k == "blur" { 0.0..=f64::INFINITY } else { crate::widgets::ANY });
let value = d.fields.contains_key(&k).then(|| d.f64(&k, 0.0));
if let Some(x) = crate::widgets::spin_length_within(ui, ("dlg-len", k.as_str()), value, unit, 140.0, bounds) {
+6 -6
View File
@@ -56,18 +56,18 @@ pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_labe
pub(crate) use labels::{label_field, take_previous_label, text_width};
pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit};
pub use numeric::{
SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_within, num_field_within_empty, opt_field,
opt_field_named, plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped, size_field, size_field_named, slider,
spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning, spin_length_within, spin_plain,
spin_plain_named, spin_range,
SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_refusing, num_field_within,
num_field_within_empty, opt_field, opt_field_named, plain_field_live, range_field, range_field_live, range_field_named, range_field_stepped,
size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_field_stops_named, spin_kerning,
spin_length_within, spin_plain, spin_plain_named, spin_range,
};
#[cfg(test)]
use numeric::{decimal_point, field_number, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth};
pub(crate) use panel_chrome::{PANEL_FOOTER, panel_footer, panel_footer_bottom, panel_footer_space, panel_list_height};
pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar};
pub(crate) use text::{
arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter,
reject_dialog_commit, set_dialog_open, take_dialog_focus,
arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, dialog_reject_pass, disarm_field_focus, enter_modifiers, focus_field,
own_field_shift_enter, reject_dialog_commit, set_dialog_open, take_dialog_focus,
};
pub use text::{
exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets,
+25 -4
View File
@@ -67,6 +67,21 @@ pub fn num_field_within(
unit_field(ui, id, label, None, value, unit, width, false, bounds, &[], false, "")
}
/// [`num_field_within`] that refuses a value typed outside `bounds` (points), as it refuses text
/// that doesn't read: the field flashes, keeps its value and holds the dialog open. ↑/↓, the wheel
/// and a scrub still stop at the ends.
pub fn num_field_refusing(
ui: &mut Ui,
id: impl std::hash::Hash,
value: Option<f64>,
unit: Unit,
width: f32,
bounds: RangeInclusive<f64>,
) -> Option<f64> {
let label = take_previous_label(ui);
unit_field_from(ui, id, label, None, value, unit, width, false, bounds, &[], false, None, "", true)
}
/// [`num_field_within`] whose empty value reads `empty` (Transform Each's Absolute scales read
/// "Mixed" when the selected objects differ): typing replaces it, as it replaces the value shown.
pub fn num_field_within_empty(
@@ -122,11 +137,12 @@ fn unit_field(
live: bool,
empty: &str,
) -> Option<f64> {
unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty)
unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None, empty, false)
}
/// [`unit_field`] whose blank field (`value` is `None`) steps from `blank_from` (points) with
/// ↑/↓ and the wheel instead of doing nothing.
/// ↑/↓ and the wheel instead of doing nothing. With `refuse_outside`, a value typed outside
/// `bounds` is refused as text that doesn't read is, rather than kept at the nearer end.
#[allow(clippy::too_many_arguments)]
fn unit_field_from(
ui: &mut Ui,
@@ -142,6 +158,7 @@ fn unit_field_from(
live: bool,
blank_from: Option<f64>,
empty: &str,
refuse_outside: bool,
) -> Option<f64> {
let id = id_with(ui.scope_id(), id);
let shown = value.map(|v| unit.format(v)).unwrap_or_else(|| empty.to_string());
@@ -163,7 +180,11 @@ fn unit_field_from(
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit);
let commit = commit && buf != shown;
if commit {
match read(&buf).or_else(|| if share_of_value { share_of(&buf, value?) } else { None }) {
let typed_value = read(&buf).or_else(|| if share_of_value { share_of(&buf, value?) } else { None });
// A refused value is one outside `bounds` that the field doesn't show as an end: the end
// shown in the field's unit ("352.7778 mm" for 1000 pt) reads back as that end.
let typed_value = typed_value.filter(|&v| !refuse_outside || bounds.contains(&v) || unit.format(within(v, &bounds)) == unit.format(v));
match typed_value {
Some(v) => {
let clamped = within(v, &bounds);
let formatted = unit.format(clamped);
@@ -1017,7 +1038,7 @@ pub fn spin_field_stops_named(
let stops = if value.is_none() { &BLANK_WEIGHT_STOPS[..] } else { presets };
let fmt = |v: f64| unit.format(v);
spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, stops, &fmt, &mut |ui, fw, label| {
unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "")
unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, stops, false, Some(blank_from), "", false)
})
.and_then(SpinPick::value)
}
+13 -2
View File
@@ -94,9 +94,20 @@ pub(crate) fn dialog_reject_commit_flag() -> egui::Id {
egui::Id::unique("dialog-reject-commit")
}
/// Suppress confirming an open dialog on Enter during this frame when an invalid value was committed.
/// The pass (`cumulative_pass_nr`) in which a field last rejected an invalid commit: a click on a
/// dialog's OK whose press came at or before it doesn't confirm the dialog.
pub(crate) fn dialog_reject_pass() -> egui::Id {
egui::Id::unique("dialog-reject-pass")
}
/// Suppress confirming an open dialog on Enter during this frame, or by the click on OK that took
/// the focus from the field, when an invalid value was committed.
pub(crate) fn reject_dialog_commit(ctx: &egui::Context) {
ctx.data_mut(|d| d.insert_temp(dialog_reject_commit_flag(), true));
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| {
d.insert_temp(dialog_reject_commit_flag(), true);
d.insert_temp(dialog_reject_pass(), pass);
});
}
/// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field
+1
View File
@@ -1303,6 +1303,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In area type of a shape other than a rectangle, each line's whole band, ascent to descent, stays inside the frame: where an edge pinches in within the band, the line ends at the narrowest point, and a line runs beside a hole that starts and ends within its band, not across it. In area type of any shape, a line also stays clear of a text wrap object that reaches into any part of its band, and inside an Invert Wrap object over its whole band (`crates/text/src/layout/region.rs` tests).
In area type whose frame has several contours, a line runs across an edge two contours share only when their edges lie exactly on one line, so a gap between contours keeps lines from crossing it, even a gap one unit in the last place wide near the edge of the canvas; an edge divided by an anchor exactly on it still lets lines cross (`layout::region::separate_slanted_contours_keep_a_four_ulp_gap_near_canvas_limit`, `shared_slanted_edge_still_joins_near_canvas_limit`, `area_type_sets_no_line_across_a_four_ulp_gap_between_slanted_contours`).
With Invert Wrap, the spans a line keeps inside a wrap shape of many parts (a comb of 500 teeth) are found by stepping through the line's spans and the shape's spans once for each piece of the band, so the work grows with the number of spans, not with its square (`crates/text/src/layout/region.rs` tests).
Text Wrap Options › Offset is a distance field in the document's units: a bare number is in those units, a typed unit ("12 pt") is read as typed, and OK sends points. In a document in picas, a bare number follows Preferences › Units › Numbers Without Units Are Points as the other length fields do: it is in points while that preference is on (the default) and in picas while it is off (`dialogs::command::tests::text_wrap_offset_in_picas_follows_numbers_without_units_are_points`). An Offset typed outside -1000 to 1000 pt, VectorCraft's own range for the command, or text that doesn't read, is refused: the field flashes and the dialog stays open, whether Enter or a click on OK ends the entry; in every dialog drawn by the shared dialog window, a click on OK no longer confirms it when the distance field it takes the focus from refuses its text (`dialogs::command::tests::text_wrap_offset_field_takes_document_units_and_refuses_values_outside_its_range`). Behavior reference: [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/enter-values-in-panels-and-dialog-boxes.html).
The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute.
The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged.
The OpenType panel's Stylistic Alternates and Titling Alternates checkboxes turn `salt` and `titl` on and off for the selected type, and its Figure menu lists Default Figure, Tabular Lining, Proportional Lining, Proportional Oldstyle and Tabular Oldstyle; Proportional Lining sets `pnum` and `lnum` and turns `tnum` and `onum` off, Tabular Lining turns `pnum` off and keeps `lnum`, Proportional Oldstyle turns `lnum` off and keeps `pnum`, and Default Figure and Tabular Oldstyle turn `pnum` and `lnum` off. Behavior reference: [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) and [Use swashes, titling alternates, or stylistic alternates](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/use-swashes-titling-alternates-or-stylistic-alternates.html) (public documentation). Tests: `panels::opentype::tests`.