mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
feat(ui): add the interface size preference (#540)
Preferences > Interface size and the `pref.uiScale` command make the whole interface larger or smaller. Auto keeps the system's display scale; a fixed size from 75 % to 300 % is absolute (100 % is one screen pixel per point on every system). The size is held back while the window is too small for it. Strings are added in en, es and fr; the control protocol, MCP, UI parity and roadmap docs are updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d8ff1441b1
commit
2c3ea03c2e
@@ -319,6 +319,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Interface size (#540): Preferences ▸ Interface size and `pref.uiScale` make the whole interface larger or smaller. Auto keeps the system's display scale (its entry shows the size it draws at); a fixed size from 75 % to 300 % is absolute, so 100 % is one screen pixel per point on every system. A window too small for a fixed size draws a smaller one until it is larger (the choice is kept); the browser keeps its own zoom. Cmd/Ctrl +/-/0 still zoom the canvas only. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
|
||||
@@ -55,3 +55,53 @@ fn reduce_motion_is_a_preference_that_persists() {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.reduce_motion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_size_is_auto_until_chosen_and_a_chosen_size_is_absolute_and_persists() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.ui_scale, None);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], "auto");
|
||||
// A fixed size: 1.0 is one screen pixel per point, so the number is not multiplied by the system's scale.
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25);
|
||||
assert_eq!(s.prefs.ui_scale, Some(1.25));
|
||||
// Reading changes nothing; "auto" goes back to the system's scale.
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], "auto");
|
||||
assert_eq!(s.prefs.ui_scale, None);
|
||||
// Nothing to undo: the document didn't change.
|
||||
assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false));
|
||||
// Saved with the other preferences.
|
||||
s.prefs.ui_scale = Some(2.5);
|
||||
let v = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(v["ui_scale"], 2.5);
|
||||
let back: crate::Prefs = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.ui_scale, Some(2.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_size_stops_at_the_ends_and_junk_never_changes_it() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX);
|
||||
// The size is a factor (1.25), not a percentage: 125 is far past the end and stops there.
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX);
|
||||
s.prefs.ui_scale = Some(1.5);
|
||||
for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] {
|
||||
assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}");
|
||||
assert_eq!(s.prefs.ui_scale, Some(1.5), "{junk}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_auto_and_keeps_the_rest() {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert_eq!(old.ui_scale, None);
|
||||
for bad in [json!("big"), json!(null), json!([2]), json!(true)] {
|
||||
let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap();
|
||||
assert_eq!((p.ui_scale, p.theme.as_str()), (None, "dark"), "{bad}");
|
||||
}
|
||||
// A number out of range is clamped rather than refused.
|
||||
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
|
||||
assert_eq!((p.ui_scale, p.theme.as_str()), (Some(crate::UI_SCALE_MAX), "dark"));
|
||||
}
|
||||
|
||||
@@ -370,6 +370,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
"Interface size",
|
||||
"{scale?: \"auto\" or a fixed size from 0.75 to 3, 1 being one screen pixel per point whatever scale the system reports, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; omit: just read it)} → {scale: \"auto\" or the size}: how large the whole interface is drawn; auto keeps the system's scale; the canvas zoom is separate",
|
||||
false,
|
||||
|s, p| {
|
||||
match get(p, "scale") {
|
||||
None => {}
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = None,
|
||||
Some(_) => {
|
||||
let scale = req_num("pref.uiScale", p, "scale")?;
|
||||
s.prefs.ui_scale =
|
||||
Some(crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number or \"auto\""))?);
|
||||
}
|
||||
}
|
||||
Ok(json!({"scale": s.prefs.ui_scale.map_or(json!("auto"), |v| json!(v))}))
|
||||
}
|
||||
),
|
||||
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
|
||||
|
||||
@@ -603,6 +603,22 @@ impl DocState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest and largest fixed interface size: 1.0 is one screen pixel per point, whatever scale
|
||||
/// the system reports.
|
||||
pub const UI_SCALE_MIN: f64 = 0.75;
|
||||
pub const UI_SCALE_MAX: f64 = 3.0;
|
||||
|
||||
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None` (Auto).
|
||||
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
|
||||
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
|
||||
}
|
||||
|
||||
/// Reads the saved interface size. Anything but a number reads as Auto and a number out of range is
|
||||
/// clamped, so a settings file edited by hand never costs the other preferences.
|
||||
fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Option<f64>, D::Error> {
|
||||
Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale))
|
||||
}
|
||||
|
||||
/// App-wide preferences that commands read (nudge amounts, behaviours).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -630,6 +646,11 @@ pub struct Prefs {
|
||||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// How large the whole interface is drawn. `None` is Auto: the scale the system reports. A
|
||||
/// number is a fixed size, 1.0 being one screen pixel per point, so it ignores the system's
|
||||
/// scale (kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]). The canvas zoom is separate.
|
||||
#[serde(deserialize_with = "read_ui_scale")]
|
||||
pub ui_scale: Option<f64>,
|
||||
/// Files that have a path are saved by themselves a few seconds after they change (the app's
|
||||
/// timer; off: only File ▸ Save writes them).
|
||||
pub auto_save: bool,
|
||||
@@ -698,6 +719,7 @@ impl Default for Prefs {
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
ui_scale: None,
|
||||
auto_save: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
|
||||
@@ -134,7 +134,9 @@ name = "English"
|
||||
"Auto (space between)" = "Auto (space between)"
|
||||
"Auto (space evenly)" = "Auto (space evenly)"
|
||||
"Auto ({mode})" = "Auto ({mode})"
|
||||
"Auto ({percent}%)" = "Auto ({percent}%)"
|
||||
"Auto columns" = "Auto columns"
|
||||
"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate."
|
||||
"Auto gap" = "Auto gap"
|
||||
"Auto height" = "Auto height"
|
||||
"Auto layout" = "Auto layout"
|
||||
@@ -653,6 +655,7 @@ name = "English"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
|
||||
"Interface size" = "Interface size"
|
||||
"Intersect" = "Intersect"
|
||||
"Intersects" = "Intersects"
|
||||
"Invalid color" = "Invalid color"
|
||||
@@ -1371,6 +1374,7 @@ name = "English"
|
||||
"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one"
|
||||
"The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}."
|
||||
"The whole file" = "The whole file"
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%."
|
||||
"Theme" = "Theme"
|
||||
"There's no destination for that action" = "There's no destination for that action"
|
||||
"Thickness" = "Thickness"
|
||||
@@ -1450,6 +1454,7 @@ name = "English"
|
||||
"Use number keys for opacity" = "Use number keys for opacity"
|
||||
"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension."
|
||||
"Use the layer's fill" = "Use the layer's fill"
|
||||
"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size."
|
||||
"Used in {files} · {instances}" = "Used in {files} · {instances}"
|
||||
"Username" = "Username"
|
||||
"Value" = "Value"
|
||||
@@ -1720,6 +1725,7 @@ name = "English"
|
||||
"{n}m" = "{n}m"
|
||||
"{n}w" = "{n}w"
|
||||
"{page}, nothing selected" = "{page}, nothing selected"
|
||||
"{percent}%" = "{percent}%"
|
||||
"{profile} (current)" = "{profile} (current)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)."
|
||||
|
||||
@@ -8,6 +8,8 @@ name = "Español"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"Auto ({percent}%)" = "Auto ({percent}%)"
|
||||
"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto sigue el escalado de pantalla del sistema; los demás tamaños lo ignoran. El zoom del documento es independiente."
|
||||
"Compatibility notes" = "Notas de compatibilidad"
|
||||
"Couldn't download the variables" = "No se pudieron descargar las variables"
|
||||
|
||||
@@ -654,6 +656,7 @@ status = "draft — needs native review"
|
||||
"Interaction" = "Interacción"
|
||||
"Interactions" = "Interacciones"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
|
||||
"Interface size" = "Tamaño de la interfaz"
|
||||
"Intersect" = "Intersecar"
|
||||
"Intersects" = "Intersecciones"
|
||||
"Invalid color" = "Color no válido"
|
||||
@@ -1372,6 +1375,7 @@ status = "draft — needs native review"
|
||||
"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno"
|
||||
"The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}."
|
||||
"The whole file" = "El archivo completo"
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%."
|
||||
"Theme" = "Tema"
|
||||
"There's no destination for that action" = "No hay destino para esa acción"
|
||||
"Thickness" = "Grosor"
|
||||
@@ -1451,6 +1455,7 @@ status = "draft — needs native review"
|
||||
"Use number keys for opacity" = "Usar teclas numéricas para la opacidad"
|
||||
"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig."
|
||||
"Use the layer's fill" = "Usar el relleno de la capa"
|
||||
"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz."
|
||||
"Used in {files} · {instances}" = "Usado en {files} · {instances}"
|
||||
"Username" = "Usuario"
|
||||
"Value" = "Valor"
|
||||
@@ -1721,6 +1726,7 @@ status = "draft — needs native review"
|
||||
"{n}m" = "{n} min"
|
||||
"{n}w" = "{n} sem"
|
||||
"{page}, nothing selected" = "{page}, nada seleccionado"
|
||||
"{percent}%" = "{percent}%"
|
||||
"{profile} (current)" = "{profile} (actual)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)."
|
||||
@@ -2032,6 +2038,7 @@ status = "draft — needs native review"
|
||||
"pref.reduceMotion" = "Reducir movimiento"
|
||||
"pref.renameDuplicates" = "Renombrar capas duplicadas"
|
||||
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
|
||||
"pref.uiScale" = "Tamaño de la interfaz"
|
||||
"pref.userName" = "Tu nombre en los comentarios"
|
||||
"proto.action.add" = "Añadir acción de prototipo"
|
||||
"proto.action.move" = "Reordenar acción de prototipo"
|
||||
|
||||
@@ -134,7 +134,9 @@ status = "draft — needs native review"
|
||||
"Auto (space between)" = "Auto (espace entre)"
|
||||
"Auto (space evenly)" = "Auto (espace égal)"
|
||||
"Auto ({mode})" = "Auto ({mode})"
|
||||
"Auto ({percent}%)" = "Auto ({percent} %)"
|
||||
"Auto columns" = "Colonnes auto"
|
||||
"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto suit la mise à l’échelle d’affichage de votre système ; les autres tailles l’ignorent. Le zoom du document est distinct."
|
||||
"Auto gap" = "Espacement auto"
|
||||
"Auto height" = "Hauteur auto"
|
||||
"Auto layout" = "Mise en page auto"
|
||||
@@ -652,6 +654,7 @@ status = "draft — needs native review"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
|
||||
"Interface size" = "Taille de l’interface"
|
||||
"Intersect" = "Intersection"
|
||||
"Intersects" = "Intersections"
|
||||
"Invalid color" = "Couleur non valide"
|
||||
@@ -1366,6 +1369,7 @@ status = "draft — needs native review"
|
||||
"The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un"
|
||||
"The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}."
|
||||
"The whole file" = "Le fichier entier"
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La fenêtre est trop petite pour {size} %, l’interface est donc affichée à {drawn} %. Agrandissez la fenêtre pour utiliser {size} %."
|
||||
"Theme" = "Thème"
|
||||
"There's no destination for that action" = "Cette action n’a pas de destination"
|
||||
"Thickness" = "Épaisseur"
|
||||
@@ -1443,6 +1447,7 @@ status = "draft — needs native review"
|
||||
"Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité"
|
||||
"Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig."
|
||||
"Use the layer's fill" = "Utiliser le remplissage du calque"
|
||||
"Use your browser's zoom to change the interface size." = "Utilisez le zoom de votre navigateur pour modifier la taille de l’interface."
|
||||
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
|
||||
"Username" = "Nom d’utilisateur"
|
||||
"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode"
|
||||
@@ -1710,6 +1715,7 @@ status = "draft — needs native review"
|
||||
"{n}m" = "{n} min"
|
||||
"{n}w" = "{n} sem."
|
||||
"{page}, nothing selected" = "{page}, aucune sélection"
|
||||
"{percent}%" = "{percent} %"
|
||||
"{profile} (current)" = "{profile} (actuel)"
|
||||
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel."
|
||||
"{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)."
|
||||
@@ -2021,6 +2027,7 @@ status = "draft — needs native review"
|
||||
"pref.reduceMotion" = "Réduire les animations"
|
||||
"pref.renameDuplicates" = "Renommer les calques dupliqués"
|
||||
"pref.showDimensions" = "Afficher les dimensions sur les objets"
|
||||
"pref.uiScale" = "Taille de l’interface"
|
||||
"pref.userName" = "Votre nom dans les commentaires"
|
||||
"proto.action.add" = "Ajouter une action de prototype"
|
||||
"proto.action.move" = "Réordonner l’action de prototype"
|
||||
|
||||
@@ -273,6 +273,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
interface_size(app, ui, prefs.ui_scale);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Theme"));
|
||||
for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] {
|
||||
@@ -475,6 +477,52 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value {
|
||||
|
||||
/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers
|
||||
/// start, a preview of the first new names, then one `object.batchRename`.
|
||||
/// Preferences ▸ Interface size: Auto (the system's scale) or a fixed size, as a drop-down that
|
||||
/// takes effect at once, with what each means written out. The browser has its own zoom, so there
|
||||
/// the row only says so.
|
||||
fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: Option<f64>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
ui.label(tr("Interface size"));
|
||||
crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary);
|
||||
return;
|
||||
}
|
||||
let percent = |scale: f32| crate::ui_scale::percent(scale);
|
||||
let auto = trf("Auto ({percent}%)", &[("percent", &percent(app.ui.ui_native))]);
|
||||
let fixed = |size: f64| trf("{percent}%", &[("percent", &percent(size as f32))]);
|
||||
let shown = chosen.map_or_else(|| auto.clone(), fixed);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Interface size"));
|
||||
let resp = crate::widgets::dropdown(ui, &shown, 120.0, None);
|
||||
crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp));
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
if crate::right::menu_check(ui, chosen.is_none(), &auto, "") {
|
||||
app.run("pref.uiScale", json!({"scale": "auto"}));
|
||||
ui.close();
|
||||
}
|
||||
for size in crate::ui_scale::SIZES {
|
||||
if crate::right::menu_check(ui, chosen == Some(size), &fixed(size), "") {
|
||||
app.run("pref.uiScale", json!({"scale": size}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
crate::widgets::label(
|
||||
ui,
|
||||
tr("Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate."),
|
||||
t.text_secondary,
|
||||
);
|
||||
// Said in words, not only by the menu's value: the window is too small for the size chosen.
|
||||
if let Some(size) = chosen.filter(|_| app.ui.ui_scale.limited) {
|
||||
let note = trf(
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.",
|
||||
&[("size", &percent(size as f32)), ("drawn", &percent(app.ui.ui_scale.drawn))],
|
||||
);
|
||||
crate::widgets::label(ui, ¬e, t.text_secondary);
|
||||
}
|
||||
}
|
||||
|
||||
fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) {
|
||||
let Some(mut b) = app.ui.batch_rename.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
|
||||
@@ -61,6 +61,7 @@ pub mod tabs;
|
||||
pub mod theme;
|
||||
pub mod toolbar;
|
||||
pub mod typography;
|
||||
pub mod ui_scale;
|
||||
pub mod variable_ui;
|
||||
pub mod variables_panel;
|
||||
pub mod vector_panel;
|
||||
@@ -204,6 +205,8 @@ mod text_enter_tests;
|
||||
#[cfg(test)]
|
||||
mod titlebar_tests;
|
||||
#[cfg(test)]
|
||||
mod ui_scale_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_export_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_rename_tests;
|
||||
@@ -459,6 +462,11 @@ pub struct UiState {
|
||||
pub menu_input: Option<dialogs::MenuInput>,
|
||||
/// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame.
|
||||
pub rename_file: bool,
|
||||
/// What the interface size drew last frame: the system's scale (what Auto shows) and whether
|
||||
/// the window held a fixed size back. Set by [`FigApp::logic`] for the main window.
|
||||
pub ui_scale: ui_scale::Plan,
|
||||
/// The system's scale of the main window, as of the last frame.
|
||||
pub ui_native: f32,
|
||||
pub shortcuts_panel: bool,
|
||||
/// The Keyboard shortcuts panel's tab and search text.
|
||||
pub shortcuts_tab: figcraft_engine::keymap::Group,
|
||||
@@ -612,6 +620,8 @@ impl Default for UiState {
|
||||
preferences: false,
|
||||
menu_input: None,
|
||||
rename_file: false,
|
||||
ui_scale: ui_scale::plan(None, None, None),
|
||||
ui_native: 1.0,
|
||||
shortcuts_panel: false,
|
||||
shortcuts_tab: figcraft_engine::keymap::Group::Essential,
|
||||
shortcuts_query: String::new(),
|
||||
@@ -1261,6 +1271,17 @@ impl FigApp {
|
||||
theme::install_fonts(ctx, true, cjk);
|
||||
}
|
||||
}
|
||||
// egui's zoom is for the whole app, so only the main window sets it (a window on another
|
||||
// screen has its own system scale). The browser keeps its own zoom: Auto there.
|
||||
if ctx.viewport_id() == egui::ViewportId::ROOT {
|
||||
let native = ctx.native_pixels_per_point();
|
||||
let window_px = ctx.content_rect().size() * ctx.pixels_per_point();
|
||||
let fixed = if cfg!(target_arch = "wasm32") { None } else { self.session.prefs.ui_scale };
|
||||
let plan = ui_scale::plan(fixed, native, Some((window_px.x, window_px.y)));
|
||||
ctx.set_zoom_factor(plan.zoom);
|
||||
self.ui.ui_scale = plan;
|
||||
self.ui.ui_native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0);
|
||||
}
|
||||
let dark = match self.session.prefs.theme.as_str() {
|
||||
"dark" => true,
|
||||
// The active visuals were set by us last frame (possibly for a manual theme).
|
||||
@@ -1679,6 +1700,8 @@ impl FigApp {
|
||||
"importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect),
|
||||
});
|
||||
state["homeSearch"] = json!(self.ui.home_view.file_query);
|
||||
// Set apart from the literal above, which is at the macro recursion limit.
|
||||
state["uiScale"] = json!({"choice": self.session.prefs.ui_scale.map_or(json!("auto"), |v| json!(v)), "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited});
|
||||
state["pendingOpens"] = json!(self.ui.pending_opens.len());
|
||||
state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect));
|
||||
state
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes
|
||||
//! egui's zoom factor.
|
||||
//!
|
||||
//! egui draws at `pixels_per_point = zoom factor × the system's scale`. Auto leaves the zoom at 1 so
|
||||
//! the system's scale rules. A fixed size is absolute: 100 % is one screen pixel per point on every
|
||||
//! system, so the zoom is the size divided by the system's scale (a Retina Mac at 2× needs 0.5 to
|
||||
//! show 100 %). A fixed size is held back while the window is too small to lay the interface out at
|
||||
//! it; the saved choice never changes.
|
||||
|
||||
/// The smallest window, in points, the interface is laid out for: the main window's minimum size.
|
||||
pub const FIT_POINTS: (f32, f32) = (800.0, 500.0);
|
||||
|
||||
/// The zoom factors egui's own settings window allows.
|
||||
const ZOOM_RANGE: (f32, f32) = (0.1, 10.0);
|
||||
|
||||
/// What to give egui for a saved choice on this screen and window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Plan {
|
||||
/// For `Context::set_zoom_factor`.
|
||||
pub zoom: f32,
|
||||
/// Physical pixels per point once drawn (the zoom times the system's scale).
|
||||
pub drawn: f32,
|
||||
/// A fixed size was chosen but the window is too small for it, so a smaller one is drawn.
|
||||
pub limited: bool,
|
||||
}
|
||||
|
||||
/// The plan for `choice` (`None`: Auto), the system's scale `native` and the window's size in
|
||||
/// physical pixels `window_px`. An unknown, zero or not finite value reads as no information: a
|
||||
/// system scale of 1 and a window of any size.
|
||||
pub fn plan(choice: Option<f64>, native: Option<f32>, window_px: Option<(f32, f32)>) -> Plan {
|
||||
let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0);
|
||||
let Some(want) = choice.map(|c| c as f32).filter(|c| c.is_finite() && *c > 0.0) else {
|
||||
return Plan { zoom: 1.0, drawn: native, limited: false };
|
||||
};
|
||||
let fit =
|
||||
window_px.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1));
|
||||
// Never below 1 (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further.
|
||||
let target = match fit {
|
||||
Some(fit) if want > fit => fit.max(want.min(1.0)),
|
||||
_ => want,
|
||||
};
|
||||
let zoom = (target / native).clamp(ZOOM_RANGE.0, ZOOM_RANGE.1);
|
||||
let drawn = zoom * native;
|
||||
Plan { zoom, drawn, limited: drawn < want - 0.005 }
|
||||
}
|
||||
|
||||
/// A size as the percentage Preferences shows (1.25 → 125).
|
||||
pub fn percent(scale: f32) -> u32 {
|
||||
(scale * 100.0).round().clamp(0.0, 100_000.0) as u32
|
||||
}
|
||||
|
||||
/// The fixed sizes Preferences offers, after Auto.
|
||||
pub const SIZES: [f64; 7] = [0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BIG: Option<(f32, f32)> = Some((5000.0, 3000.0));
|
||||
|
||||
#[test]
|
||||
fn auto_keeps_the_systems_scale_on_every_screen() {
|
||||
for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
|
||||
let p = plan(None, Some(native), BIG);
|
||||
assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "{native}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_size_is_the_same_size_whatever_the_systems_scale() {
|
||||
// Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen.
|
||||
for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
|
||||
for size in SIZES {
|
||||
let p = plan(Some(size), Some(native), BIG);
|
||||
assert!((p.drawn - size as f32).abs() < 1e-5, "{size} on {native}: drawn {}", p.drawn);
|
||||
assert!(!p.limited, "{size} on {native}");
|
||||
}
|
||||
}
|
||||
// 100 % on a 2× screen really is half the Auto size: the zoom is 0.5.
|
||||
assert_eq!(plan(Some(1.0), Some(2.0), BIG).zoom, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() {
|
||||
for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] {
|
||||
for choice in [None, Some(1.5), Some(f64::NAN), Some(f64::INFINITY), Some(-1.0), Some(0.0), Some(1e300)] {
|
||||
let p = plan(choice, native, Some((f32::NAN, 0.0)));
|
||||
assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}");
|
||||
assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}");
|
||||
}
|
||||
}
|
||||
// A choice that isn't a usable number is Auto.
|
||||
assert_eq!(plan(Some(f64::NAN), Some(2.0), BIG), plan(None, Some(2.0), BIG));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_window_holds_a_big_size_back_without_going_below_100() {
|
||||
// The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels.
|
||||
let min = Some((1600.0, 1000.0));
|
||||
let p = plan(Some(3.0), Some(2.0), min);
|
||||
assert_eq!((p.drawn, p.limited), (2.0, true));
|
||||
assert!(!plan(Some(2.0), Some(2.0), min).limited);
|
||||
// The same window on a 1× screen: 800 × 500 pixels hold only 100 %.
|
||||
let p = plan(Some(2.0), Some(1.0), Some((800.0, 500.0)));
|
||||
assert_eq!((p.drawn, p.limited), (1.0, true));
|
||||
// A window smaller than the minimum (a tiling window manager): never below 100 %, and never
|
||||
// below a smaller size the person chose.
|
||||
assert_eq!(plan(Some(2.0), Some(1.0), Some((400.0, 250.0))).drawn, 1.0);
|
||||
let p = plan(Some(0.75), Some(1.0), Some((400.0, 250.0)));
|
||||
assert_eq!((p.drawn, p.limited), (0.75, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() {
|
||||
// The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too.
|
||||
let px = Some((1700.0, 1100.0));
|
||||
let a = plan(Some(2.5), Some(2.0), px);
|
||||
let b = plan(Some(2.5), Some(2.0), px);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentages_round_like_the_menu_shows_them() {
|
||||
assert_eq!([percent(0.75), percent(1.0), percent(1.25), percent(2.0), percent(1.7999)], [75, 100, 125, 200, 180]);
|
||||
assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The interface size, driven through egui headless on screens of different system scales: Auto
|
||||
//! leaves the system's scale alone, a fixed size comes out the same on every screen, and the
|
||||
//! Preferences drop-down tells a screen reader its name and value.
|
||||
//!
|
||||
//! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small
|
||||
//! window is tested on its own in `ui_scale::tests`, and the sizes below stay inside it.
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{RawInput, ViewportId, ViewportInfo};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// One frame on a screen whose system scale is `native` (a real window reports it every frame).
|
||||
fn frame_at(u: &mut Ui, native: f32) -> egui::FullOutput {
|
||||
let mut raw = RawInput::default();
|
||||
raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() });
|
||||
u.frame_raw(raw)
|
||||
}
|
||||
|
||||
/// Frames on that screen, enough for a new zoom to take effect.
|
||||
fn settle(u: &mut Ui, native: f32) {
|
||||
for _ in 0..4 {
|
||||
frame_at(u, native);
|
||||
}
|
||||
}
|
||||
|
||||
const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0];
|
||||
|
||||
#[test]
|
||||
fn auto_leaves_the_systems_scale_and_the_menu_can_say_what_it_is() {
|
||||
for native in SCREENS {
|
||||
let mut u = Ui::new(Session::new());
|
||||
settle(&mut u, native);
|
||||
assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}");
|
||||
assert_eq!(u.ctx.pixels_per_point(), native, "{native}");
|
||||
assert_eq!(u.app.ui.ui_native, native, "what Auto shows in the menu: {native}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_size_draws_the_same_on_every_screen_and_auto_brings_the_systems_scale_back() {
|
||||
for native in SCREENS {
|
||||
let mut u = Ui::new(Session::new());
|
||||
for size in [0.75_f64, 1.0, 1.5] {
|
||||
assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some());
|
||||
settle(&mut u, native);
|
||||
let drawn = u.ctx.pixels_per_point();
|
||||
assert!((drawn - size as f32).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}");
|
||||
assert!(!u.app.ui.ui_scale.limited, "{size} on {native}");
|
||||
}
|
||||
assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some());
|
||||
settle(&mut u, native);
|
||||
assert_eq!((u.ctx.zoom_factor(), u.ctx.pixels_per_point()), (1.0, native), "{native}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.ui.preferences = true;
|
||||
u.ctx.enable_accesskit();
|
||||
let combo = |u: &mut Ui| {
|
||||
settle(u, 2.0);
|
||||
let out = frame_at(u, 2.0);
|
||||
let nodes: Vec<_> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
|
||||
let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu");
|
||||
(n.value().map(str::to_owned), n.is_expanded())
|
||||
};
|
||||
// Auto says the size it draws at: a 2× screen is 200 %.
|
||||
assert_eq!(combo(&mut u), (Some("Auto (200%)".to_owned()), Some(false)));
|
||||
u.app.run("pref.uiScale", json!({"scale": 1.25}));
|
||||
assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false)));
|
||||
}
|
||||
@@ -169,7 +169,9 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)).
|
||||
`canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last
|
||||
pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a
|
||||
screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or
|
||||
the system's accessibility setting turns the interface's animations off. `live` is the live
|
||||
the system's accessibility setting turns the interface's animations off. `uiScale` is the interface
|
||||
size: the `choice` (`"auto"` or a fixed size, `pref.uiScale`), what is `drawn` (physical pixels per
|
||||
point) and whether the window is too small for the choice (`limited`, so `drawn` is smaller). `live` is the live
|
||||
session of the window's active file as the window shows it (sessions are per file: another tab
|
||||
shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one),
|
||||
`sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the
|
||||
|
||||
@@ -417,6 +417,12 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||||
nested layers), Escape or a click drops it.
|
||||
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
|
||||
accessibility setting turns it on too). Prototype transitions still play.
|
||||
- `pref.uiScale {scale?}`: how large the whole interface is drawn. `scale` is `"auto"` (the system's
|
||||
display scale, the default) or a fixed size from 0.75 to 3 where 1 is one screen pixel per point
|
||||
whatever scale the system reports, so a 2× screen needs 2 to match Auto. 1.25 means 125 %, not 125
|
||||
(outside the range it stops at the nearest end). Omit `scale` to read it. The canvas zoom is separate.
|
||||
A window too small for a fixed size draws a smaller one until it is larger; the choice is kept.
|
||||
In the browser the interface always follows the browser's zoom.
|
||||
- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change
|
||||
(off by default; the desktop app's timer does it, so scripts still call `file.save`).
|
||||
- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system":
|
||||
|
||||
@@ -165,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| Keyboard navigation: F6 regions, Tab within, focus ring | done | |
|
||||
| Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` |
|
||||
| Reduced motion | done | system setting or preference |
|
||||
| Interface size (Preferences ▸ Interface size, `pref.uiScale`) | done on desktop | Auto (the system's scale) or a fixed size from 75 % to 300 %, where 100 % is one screen pixel per point on every system; held back while the window is too small for it; the browser keeps its own zoom |
|
||||
| WCAG AA contrast of secondary and accent text | done | |
|
||||
| Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA |
|
||||
|
||||
@@ -176,6 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |
|
||||
| 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged |
|
||||
| 2026-10-17 | minor | Interface size preference (#540): Preferences ▸ Interface size and `pref.uiScale`, Auto or a fixed size from 75 % to 300 %, absolute (100 % is one screen pixel per point whatever the system's scale) |
|
||||
| 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker |
|
||||
| 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user