fix(text): variable font sliders preview live as one undo step; Character panel layout

Dragging a variable font axis slider previews the change on the page and
commits it as one undo step when the slider is let go. The panel gathers what
every axis slider did in a frame before acting, so with several axes a drag no
longer commits and reopens the change on every frame. A click, an arrow key or
a value typed in the field beside each slider applies at once. Narrow axes
(Skia's weight runs 0.48-3.2) step by hundredths; a slider snapping a value
that is off its steps no longer counts as a change.

Live previews are commands, so the panel stays on app.run: edit.preview
{label, command, params} shows an edit on the document as it was before the
preview began, edit.commitPreview makes it one undo step, edit.cancelPreview
drops it. A preview no panel held in the last frame (its slider let go, or the
panel closed) is committed at the start of the next one.

The Character panel drops its repeated heading, lays Size, Kerning and
Baseline, and Leading and Tracking, out in two columns whose captions and
fields line up, shows auto leading in parentheses, and shows a style with
axis settings as its axes alone, here and in the Text Frame floating bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andres Torresi
2026-10-15 10:23:55 +01:00
co-authored by Claude Opus 5.5
parent a930276fd7
commit a4e02ed4fd
6 changed files with 380 additions and 36 deletions
+1
View File
@@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-15** · Character panel: dragging a variable font axis slider previews on the page and is one undo step when let go (with several axes it piled up an undo step per frame); each axis takes a typed value, and narrow axes (Skia's 0.48–3.2) step by hundredths instead of jumping to whole numbers. The fields line up in two columns, auto leading shows in parentheses, and an axis-set style shows its axes. New `edit.preview` / `edit.commitPreview` / `edit.cancelPreview` commands give panels, the control channel and MCP live previews. No readiness percentage change.
- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change.
- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change.
- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change.
+101
View File
@@ -342,9 +342,47 @@ pub fn specs() -> Vec<CommandSpec> {
.execute("edit.paste", &json!({"inPlace": true}))),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Alt+Shift+D"), "{}", has_selection, |s, _| s
.execute("transform.move", &json!({"dx": 12.0, "dy": 12.0, "copy": true}))),
// Live previews (a panel slider being dragged): the change shows on the page, and only
// `edit.commitPreview` makes it one undo step.
cmd!(
query "edit.preview",
"Preview Change",
[],
None,
"{label, command, params?} — run `command` on the document as it was before the preview began, replacing the last preview; the first call opens a preview named `label` (the undo step's name)",
has_doc,
preview
),
cmd!(query "edit.commitPreview", "Commit Preview", [], None, "{} — the open preview becomes one undo step", has_doc, |s, _| {
s.commit_interaction()?;
ok()
}),
cmd!(query "edit.cancelPreview", "Cancel Preview", [], None, "{} — the document goes back to how it was before the preview", has_doc, |s, _| {
s.cancel_interaction();
ok()
}),
]
}
/// `edit.preview`: open the preview (unless one of the same name is open) and show `command` on it.
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
let label = super::str_param(p, "label").filter(|l| !l.is_empty()).ok_or_else(|| super::bad("edit.preview", "missing label"))?;
let command = super::str_param(p, "command").ok_or_else(|| super::bad("edit.preview", "missing command"))?;
// Only an edit can be previewed: a query, undo or another preview command would not be one undo step.
if !super::find_command(command).is_some_and(|c| c.undoable) {
return Err(super::bad("edit.preview", format!("{command} is not an edit that can be previewed")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
match s.doc()?.interaction.as_ref().map(|i| i.label.as_str()) {
None => s.begin_interaction(label)?,
Some(open) if open == label => {}
// A tool's drag (or another preview) is in progress: leave it alone.
Some(open) => return Err(super::bad("edit.preview", format!("{open} is in progress"))),
}
s.preview(command, params)?;
ok()
}
/// A document holding copies of the selected items (and their stories) on spread 0.
pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
let st = s.doc()?;
@@ -813,3 +851,66 @@ mod paste_into_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod preview_tests {
use super::*;
fn text_session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap();
let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap();
s
}
fn size(s: &mut Session) -> f64 {
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64().unwrap()
}
fn preview_size(s: &mut Session, v: f64) {
s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": v}}})).unwrap();
}
#[test]
fn previews_show_on_the_page_and_commit_as_one_undo_step() {
let mut s = text_session();
let (before, undo) = (size(&mut s), s.doc().unwrap().history.undo.len());
for v in [20.0, 30.0, 40.0] {
preview_size(&mut s, v);
assert_eq!(size(&mut s), v, "each preview replaces the last");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step while previewing");
s.execute("edit.commitPreview", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert!(s.doc().unwrap().interaction.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(size(&mut s), before);
}
#[test]
fn a_cancelled_preview_leaves_the_document_as_it_was() {
let mut s = text_session();
let (before, undo) = (size(&mut s), s.doc().unwrap().history.undo.len());
preview_size(&mut s, 50.0);
s.execute("edit.cancelPreview", &json!({})).unwrap();
assert_eq!(size(&mut s), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Committing with nothing open does nothing.
s.execute("edit.commitPreview", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn only_edits_are_previewed_and_another_interaction_is_left_alone() {
let mut s = text_session();
for command in ["edit.preview", "edit.undo", "type.selectionAttrs", "no.such.command"] {
assert!(s.execute("edit.preview", &json!({"label": "X", "command": command})).is_err(), "{command}");
}
assert!(s.execute("edit.preview", &json!({"command": "type.char"})).is_err(), "needs a label");
s.begin_interaction("Move").unwrap();
assert!(s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": 20}}})).is_err());
assert_eq!(s.doc().unwrap().interaction.as_ref().unwrap().label, "Move");
}
}
+41
View File
@@ -568,6 +568,15 @@ pub struct DesignApp {
quitting: Option<QuitState>,
/// The tool cursor bitmaps handed to the OS.
pub cursors: cursors::Images,
/// A panel's live preview (a slider being dragged), committed when it is let go.
live_preview: LivePreview,
}
/// The preview a panel opened with `edit.preview`: it stays open while a panel holds it each frame.
#[derive(Default)]
struct LivePreview {
open: bool,
held: bool,
}
struct QuitState {
@@ -622,6 +631,7 @@ impl DesignApp {
quit_without_asking: false,
quitting: None,
cursors: cursors::Images::default(),
live_preview: LivePreview::default(),
}
}
@@ -703,6 +713,36 @@ impl DesignApp {
r
}
/// Show `command` on the page as a live preview named `label` (the undo step's name): each call
/// replaces the last, and nothing reaches the undo history until [`Self::end_live_preview`]. A
/// panel calls this, or [`Self::hold_live_preview`], every frame its control is held; a frame
/// without either commits the preview.
pub fn live_preview(&mut self, label: &str, command: &str, params: Value) {
if self.run("edit.preview", json!({"label": label, "command": command, "params": params})).is_ok() {
self.live_preview = LivePreview { open: true, held: true };
}
}
/// The control showing the live preview is still held (pressed, though not moved this frame).
pub fn hold_live_preview(&mut self) {
self.live_preview.held = self.live_preview.open;
}
/// Commit the live preview, if one is open, as one undo step.
pub fn end_live_preview(&mut self) {
if std::mem::take(&mut self.live_preview).open {
let _ = self.run("edit.commitPreview", json!({}));
}
}
/// Start of a frame: a live preview no panel held last frame (its control was let go, or the
/// panel closed) is committed, so it never swallows later edits' undo steps.
pub(crate) fn settle_live_preview(&mut self) {
if !std::mem::take(&mut self.live_preview.held) {
self.end_live_preview();
}
}
/// Paste without the clipboard's text (a menu, not the paste keys): read it here. With no
/// insertion point, text that doesn't stand for copied objects becomes a new text frame
/// centred in the view.
@@ -1248,6 +1288,7 @@ impl DesignApp {
ctx.request_repaint();
return;
}
self.settle_live_preview();
let t0 = now_ms();
let t = theme::Tokens::get(&ctx);
let presenting = self.ui.screen_mode == ScreenMode::Presentation;
+20 -1
View File
@@ -524,10 +524,22 @@ pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str,
}
}
/// The style a style field shows: `—` for none; a style with axis settings is no longer its named
/// style, so it shows its axes alone (`Cd Hair {wght:620}` → `(wght:620)`).
pub fn style_shown(current: &str) -> String {
if current.is_empty() {
"—".to_string()
} else if let Some((_, axes)) = current.split_once('{') {
format!("({})", axes.trim_end_matches('}').trim())
} else {
current.to_string()
}
}
pub fn font_style_picker(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, current: &str, width: f32) {
let styles = fonts(app).styles(family);
let mut pick = None;
egui::ComboBox::from_id_salt("font_style").selected_text(if current.is_empty() { "—" } else { current }).width(width).show_ui(ui, |ui| {
egui::ComboBox::from_id_salt("font_style").selected_text(style_shown(current)).width(width).show_ui(ui, |ui| {
for s in &styles {
if ui.selectable_label(s == current, s).clicked() {
pick = Some(s.clone());
@@ -563,6 +575,13 @@ mod font_menu_tests {
use super::*;
#[test]
fn a_style_with_axis_settings_shows_its_axes_alone() {
assert_eq!(style_shown("Cd Hair {wdth:110,wght:620,ital:1}"), "(wdth:110,wght:620,ital:1)");
assert_eq!(style_shown("Cd Hair"), "Cd Hair");
assert_eq!(style_shown(""), "—");
}
#[test]
fn cjk_fonts_follow_the_western_ones_under_their_native_names() {
const FAMILY: &str = "DC UI Test Mincho";
+216 -34
View File
@@ -1322,7 +1322,6 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
let c = a["chars"].clone();
let fam = c["fontFamily"].as_str().unwrap_or("").to_string();
let sty = c["fontStyle"].as_str().unwrap_or("").to_string();
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Character")).strong());
super::font_family_picker(app, ui, &fam, 220.0);
super::font_style_picker(app, ui, &fam, &sty, 220.0);
variable_font_axes(app, ui, &fam, &sty);
@@ -1354,30 +1353,32 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
});
}
egui::Grid::new("chargrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Size"));
if let Some(v) = number(ui, "pcs", c["size"].as_f64(), " pt", 60.0, 2) {
// Two columns: Size, Kerning and Baseline on the left, Leading and Tracking on the right. A
// column's captions share the width of its widest one and each field takes the rest of the
// column, so they line up.
let auto = c["leading"]["kind"].as_str() != Some("points");
let lv = if auto { c["size"].as_f64().map(|s| s * a["para"]["autoLeading"].as_f64().unwrap_or(1.2)) } else { c["leading"]["value"].as_f64() };
let (left_w, right_w) = (caption_width(app, ui, &["Size", "Kerning", "Baseline"]), caption_width(app, ui, &["Leading", "Tracking"]));
ui.columns(2, |cols| {
let [left, right] = cols else { return };
if let Some(v) = char_field(app, left, "Size", left_w, NumField::number("pcs", c["size"].as_f64(), " pt", 2)) {
let _ = app.run("type.char", json!({"attrs": {"size": v}}));
}
caption(ui, crate::i18n::tr(&app.ui.language, "Leading"));
let auto = c["leading"]["kind"].as_str() != Some("points");
let lv = if auto { c["size"].as_f64().map(|s| s * a["para"]["autoLeading"].as_f64().unwrap_or(1.2)) } else { c["leading"]["value"].as_f64() };
if let Some(v) = number(ui, "pcl", lv, if auto { crate::i18n::tr(&app.ui.language, " (auto)") } else { " pt" }, 70.0, 2) {
let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
}
ui.end_row();
caption(ui, crate::i18n::tr(&app.ui.language, "Kerning"));
kerning_control(app, ui, "pck", &c["kerning"], 60.0);
caption(ui, crate::i18n::tr(&app.ui.language, "Tracking"));
if let Some(v) = number(ui, "pct", c["tracking"].as_f64(), "", 70.0, 0) {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
}
ui.end_row();
caption(ui, crate::i18n::tr(&app.ui.language, "Baseline"));
if let Some(v) = number(ui, "pcb", c["baselineShift"].as_f64(), " pt", 70.0, 2) {
left.horizontal(|ui| {
caption_cell(ui, crate::i18n::tr(&app.ui.language, "Kerning"), left_w);
let width = (ui.available_width() - ui.spacing().item_spacing.x).max(40.0);
kerning_control(app, ui, "pck", &c["kerning"], width);
});
if let Some(v) = char_field(app, left, "Baseline", left_w, NumField::number("pcb", c["baselineShift"].as_f64(), " pt", 2)) {
let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}}));
}
ui.end_row();
// Auto leading is shown in parentheses, as the Paragraph panel shows it.
if let Some(v) = char_field(app, right, "Leading", right_w, NumField::number("pcl", lv, " pt", 2).parens(auto)) {
let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
}
if let Some(v) = char_field(app, right, "Tracking", right_w, NumField::number("pct", c["tracking"].as_f64(), "", 0)) {
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
}
});
ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Color"));
@@ -2441,7 +2442,9 @@ pub fn attributes_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// Variable Font sliders: one per axis; the style becomes `Named {tag:value,…}`.
/// Variable Font sliders: one per axis, each with a field for a typed value; the style becomes
/// `Named {tag:value,…}`. Dragging a slider previews on the page and is one undo step once let go;
/// a click, an arrow key or a typed value applies at once.
fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, style: &str) {
if family.is_empty() {
return;
@@ -2455,21 +2458,97 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl
let face = db.face(family, style);
let cur = |tag: &str, default: f32| face.coords.iter().find(|(t, _)| t == tag.as_bytes()).map_or(default, |c| c.1);
let mut values: Vec<(String, f32)> = axes.iter().map(|a| (a.0.clone(), cur(&a.0, a.3))).collect();
let mut changed = false;
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Variable Font")).size(11.0));
egui::Grid::new("vf_axes").num_columns(2).spacing(vec2(6.0, 2.0)).show(ui, |ui| {
for (i, (_, name, min, _, max)) in axes.iter().enumerate() {
caption(ui, name);
if ui.add(egui::Slider::new(&mut values[i].1, *min..=*max).step_by(1.0)).drag_stopped() {
changed = true;
// What the sliders did this frame, gathered over every axis before anything is run.
let (mut held, mut dragged, mut applied) = (false, false, false);
let names: Vec<&str> = axes.iter().map(|a| a.1.as_str()).collect();
let label_w = caption_width(app, ui, &names);
for ((tag, name, min, _, max), value) in axes.iter().zip(values.iter_mut()) {
// Caption, a slider filling the middle, then the value field: every slider is as long.
ui.horizontal(|ui| {
caption_cell(ui, name, label_w);
ui.spacing_mut().slider_width = (ui.available_width() - PANEL_FIELD_W - ui.spacing().item_spacing.x * 2.0).max(40.0);
let (step, decimals) = axis_step(*min, *max);
// The field beside the slider takes typed values, so the slider's own is hidden.
let slider = ui.add(egui::Slider::new(&mut value.1, *min..=*max).step_by(f64::from(step)).show_value(false));
// Only the user's own change counts: the slider also reports one when it snaps a value
// that is off its steps, which it would then do on every frame.
let pressed = slider.is_pointer_button_down_on() || slider.dragged();
let changed = slider.changed() && (pressed || slider.has_focus());
held |= pressed;
dragged |= changed && pressed;
applied |= changed && !pressed;
if let Some(v) = number(ui, &format!("vf{tag}"), Some(f64::from(value.1)), "", PANEL_FIELD_W, decimals).filter(|v| v.is_finite()) {
value.1 = (v as f32).clamp(*min, *max);
applied = true;
}
ui.end_row();
}
});
if changed {
let spec = values.iter().map(|(t, v)| format!("{t}:{}", v.round())).collect::<Vec<_>>().join(",");
let _ = app.run("type.char", json!({"attrs": {"fontStyle": format!("{base} {{{spec}}}")}}));
});
}
let attrs = json!({"attrs": {"fontStyle": variable_font_style(&base, &values)}});
if applied {
app.end_live_preview();
let _ = app.run("type.char", attrs);
} else if dragged {
app.live_preview("Variable Font", "type.char", attrs);
} else if held {
app.hold_live_preview();
}
// Let go, the preview is committed at the start of the next frame (DesignApp::ui).
}
/// The slider step and the field's decimals for an axis: whole units, or hundredths for an axis
/// whose whole range is a few units (Skia's weight runs 0.48–3.2).
fn axis_step(min: f32, max: f32) -> (f32, usize) {
if max - min >= 10.0 { (1.0, 0) } else { (0.01, 2) }
}
/// The style for the axis values: `base {tag:value,…}`, each value to at most two decimals.
fn variable_font_style(base: &str, values: &[(String, f32)]) -> String {
let spec = values
.iter()
.map(|(t, v)| {
let v = format!("{v:.2}");
format!("{t}:{}", v.trim_end_matches('0').trim_end_matches('.'))
})
.collect::<Vec<_>>()
.join(",");
format!("{base} {{{spec}}}")
}
/// The width of the axis sliders' value fields, so the sliders end at the same place.
const PANEL_FIELD_W: f32 = 52.0;
/// The caption font of the Character panel's rows.
fn caption_font() -> egui::FontId {
egui::FontId::proportional(11.5)
}
/// The width of the widest of `labels` in the interface language: a column's caption cells.
fn caption_width(app: &DesignApp, ui: &Ui, labels: &[&str]) -> f32 {
let t = Tokens::get(ui.ctx());
labels
.iter()
.map(|l| ui.painter().layout_no_wrap(crate::i18n::tr(&app.ui.language, l).to_string(), caption_font(), t.text).size().x)
.fold(0.0, f32::max)
.ceil()
}
/// A Character panel column row: a caption cell `label_w` wide, then the number field filling the
/// rest of the column. Returns the value committed.
fn char_field(app: &DesignApp, ui: &mut Ui, label: &str, label_w: f32, field: NumField<'_>) -> Option<f64> {
ui.horizontal(|ui| {
caption_cell(ui, crate::i18n::tr(&app.ui.language, label), label_w);
let width = (ui.available_width() - ui.spacing().item_spacing.x).max(40.0);
field.width(width).show(ui)
})
.inner
}
/// A left-aligned caption in a cell of fixed width, so the field after it lines up.
fn caption_cell(ui: &mut Ui, text: &str, width: f32) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::hover());
crate::rtl::paint(ui.painter(), pos2(r.min.x, r.center().y), egui::Align2::LEFT_CENTER, text, caption_font(), t.text);
}
#[cfg(test)]
@@ -2651,6 +2730,109 @@ mod tests {
assert_eq!(kerning(h.state_mut())["kind"], "none");
}
fn undo_steps(app: &DesignApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
fn font_style(app: &mut DesignApp) -> String {
text_attrs(app).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string()
}
#[test]
fn variable_font_style_keeps_two_decimals_at_most() {
let values = [("wght".to_string(), 512.0_f32), ("wdth".to_string(), 1.25), ("opsz".to_string(), 0.4801)];
assert_eq!(variable_font_style("Regular", &values), "Regular {wght:512,wdth:1.25,opsz:0.48}");
assert_eq!(axis_step(100.0, 900.0), (1.0, 0));
assert_eq!(axis_step(0.48, 3.2), (0.01, 2));
}
#[test]
fn a_live_preview_held_over_many_frames_is_one_undo_step_once_let_go() {
let mut app = text_app();
let steps = undo_steps(&app);
let size = |app: &mut DesignApp| text_attrs(app).unwrap()["chars"]["size"].as_f64().unwrap();
// A drag: a preview on most frames, held without moving on others.
for v in [20.0, 24.0, 30.0] {
app.settle_live_preview();
app.live_preview("Size", "type.char", json!({"attrs": {"size": v}}));
app.settle_live_preview();
app.hold_live_preview();
}
assert_eq!(size(&mut app), 30.0, "the page shows the last preview");
assert_eq!(undo_steps(&app), steps, "nothing is undoable while held");
// Let go: a frame without a hold, then the frame after it commits the preview.
app.settle_live_preview();
assert_eq!(undo_steps(&app), steps, "the last held frame keeps it open");
app.settle_live_preview();
assert_eq!(undo_steps(&app), steps + 1);
assert!(app.session.active().unwrap().interaction.is_none());
app.settle_live_preview();
assert_eq!(undo_steps(&app), steps + 1, "committed once");
// Later edits get their own undo steps.
app.run("type.char", json!({"attrs": {"tracking": 50}})).unwrap();
assert_eq!(undo_steps(&app), steps + 2);
}
/// A system variable font with two or more axes (macOS ships some), loaded into the shared fonts.
fn system_variable_font() -> Option<&'static str> {
let candidates = [("/System/Library/Fonts/Supplemental/Skia.ttf", "Skia"), ("/System/Library/Fonts/NewYork.ttf", "New York")];
candidates.into_iter().find_map(|(path, family)| {
let bytes = std::fs::read(path).ok()?;
let db = designcraft_fonts::FontDb::global();
db.add_font(bytes);
let style = db.styles(family).first().cloned()?;
(db.axes(family, &style).len() >= 2).then_some(family)
})
}
#[test]
fn dragging_one_of_several_axis_sliders_is_one_undo_step() {
let Some(family) = system_variable_font() else {
eprintln!("skipped: no variable font with two axes on this system");
return;
};
let mut app = text_app();
super::super::apply_font_family(&mut app, family);
let before = font_style(&mut app);
let steps = undo_steps(&app);
// The app commits a preview no panel held at the start of each frame (DesignApp::ui).
let mut h = Harness::builder().with_size(vec2(320.0, 1400.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let id = egui::Id::new("test_fonts");
if ui.data(|d| d.get_temp::<bool>(id)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(id, true));
return;
}
app.settle_live_preview();
character_panel(app, ui);
},
app,
);
h.run_steps(3);
let sliders = h.get_all_by_role(egui::accesskit::Role::Slider).map(|n| n.rect()).collect::<Vec<_>>();
assert!(sliders.len() >= 2, "one slider per axis: {sliders:?}");
let track = sliders[0];
let y = track.center().y;
h.hover_at(egui::pos2(track.left() + 2.0, y));
h.run_steps(1);
h.drag_at(egui::pos2(track.left() + 2.0, y));
h.run_steps(1);
for k in 1..=10 {
h.hover_at(egui::pos2(track.left() + track.width() * k as f32 / 10.0, y));
h.run_steps(1);
assert_eq!(undo_steps(h.state()), steps, "no undo step while dragging (frame {k})");
}
let previewed = font_style(h.state_mut());
assert_ne!(previewed, before, "the drag previews on the page");
h.drop_at(egui::pos2(track.right(), y));
h.run_steps(3);
assert_eq!(undo_steps(h.state()), steps + 1, "the whole drag is one undo step");
assert_eq!(font_style(h.state_mut()), previewed);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(font_style(h.state_mut()), before);
}
#[test]
fn character_panel_has_kerning_and_takes_a_typed_manual_value() {
let mut h = panel_harness(text_app(), character_panel);
+1 -1
View File
@@ -150,7 +150,7 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) {
}
let styles = fonts.styles(&fam);
let cur = styles.iter().position(|s| *s == sty);
if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, 90.0, &styles, cur) {
if let Some(k) = widgets::dropdown_list(ui, &panels::style_shown(&sty), 90.0, &styles, cur) {
let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}}));
}
let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());