Merge main into fix/learn-panel-commands

This commit is contained in:
dotCooCoo
2026-11-14 02:33:25 -08:00
237 changed files with 21971 additions and 1408 deletions
+1
View File
@@ -136,6 +136,7 @@ impl VectorcraftApp {
custom_titlebar: false,
caption_layout: Default::default(),
desktop_frame: false,
desktop_keeps_frame: false,
last_window_title: String::new(),
fullscreen_for_mode: false,
graphics_adapter: None,
+36 -1
View File
@@ -22,7 +22,7 @@ pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
});
}
/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links.
/// The Home screen's column: welcome, New / Open, Recent Files, Learn, presets and community links.
fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The welcome and the New / Open buttons sit centred, as PhotoCraft's Home screen does (#4684).
@@ -46,6 +46,7 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
});
});
recent(app, ui);
learn(app, ui);
ui.add_space(28.0);
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
@@ -68,6 +69,40 @@ fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
/// The id of the Home screen's Quick skill guide card.
const LEARN_GUIDE_ID: &str = "home-quick-skill-guide";
/// The Home screen's Learn section: the Quick skill guide card, which shows the Learn panel's list
/// of skills, and the Learn panel's tutorials under their skills, each a button that shows the
/// panel at its steps (`learn.show`). Neither opens, closes or switches a document, and the panel
/// shows beside the Home screen.
fn learn(app: &mut VectorcraftApp, ui: &mut Ui) {
use crate::panels::learn::{catalog, guide_card};
let t = Tokens::get(ui.ctx());
ui.add_space(28.0);
ui.label(egui::RichText::new(tl!("Learn")).font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
guide_card(app, ui, egui::Id::unique(LEARN_GUIDE_ID), t.panel);
let mut open = None;
for skill in catalog::SKILLS {
ui.add_space(10.0);
ui.label(egui::RichText::new(skill.title).size(12.0).color(t.text_dim));
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
for tutorial in skill.tutorials {
if widgets::secondary_button(ui, tutorial.title).clicked() {
open = Some(tutorial.id);
}
}
});
}
if let Some(id) = open
&& let Err(e) = app.run("learn.show", json!({ "tutorial": id }))
{
app.status(e);
}
}
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
/// folder; a click opens it. Nothing when there are none.
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
+54 -17
View File
@@ -55,6 +55,42 @@ fn selection_tool_with_selection(app: &VectorcraftApp) -> bool {
&& app.session.active().is_some_and(|st| !st.selection.is_empty())
}
/// Whether the bar shows its blank canvas actions, Draw (Pencil) and Place Image: a selection tool is
/// in use and the active document has no artwork, only layers and guides.
fn blank_canvas(app: &VectorcraftApp) -> bool {
fn no_art(nodes: &[std::sync::Arc<vectorcraft_doc::Node>]) -> bool {
nodes.iter().all(|n| n.is_guide() || (n.is_layer() && n.children().is_none_or(|c| no_art(c))))
}
matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")
&& app.session.active().is_some_and(|st| st.selection.is_empty() && no_art(&st.doc.layers))
}
/// Where the bar's top edge centers, on screen: under the lowest corner on screen of the selected
/// artboards or the selection, centered on them (the view may be rotated); on a blank canvas, which
/// has no selection, [`blank_canvas_anchor`]. None with nothing to sit under.
fn bar_anchor(app: &mut VectorcraftApp, xf: &Xf, on_artboards: bool, blank: bool) -> Option<Pos2> {
if blank {
return Some(blank_canvas_anchor(app, xf));
}
let b = if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }?;
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
Some(pos2(xf.to_screen(b.center()).x, lowest))
}
/// Where the blank canvas bar's top edge centers, on screen: under the bottom of the active artboard
/// when part of it is in view, else the canvas's center.
fn blank_canvas_anchor(app: &VectorcraftApp, xf: &Xf) -> Pos2 {
let board = app.session.active().and_then(|st| {
let last = st.doc.artboards.len().checked_sub(1)?;
st.doc.artboards.get(app.view().map_or(0, |v| v.artboard).min(last)).map(|a| a.rect)
});
let shown = board.map(|b| (b, xf.quad(b))).filter(|(_, q)| egui::Rect::from_points(q).intersects(xf.rect));
match shown {
Some((b, q)) => pos2(xf.to_screen(b.center()).x, q.iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max)),
None => xf.rect.center(),
}
}
/// Whether a Type tool edits type, or has type selected: its bar then shows only the text
/// formatting controls.
fn type_tool_on_type(app: &VectorcraftApp) -> bool {
@@ -75,6 +111,8 @@ pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool {
/// While a Type tool edits type, or has type selected, the bar shows only those controls and the
/// Fill. When one row would be wider than the canvas, the font controls take a row of their own,
/// narrowed to fit it, and the actions wrap onto rows under the handle's ([`bar_layout`]).
/// On a blank canvas, a document with no artwork and a selection tool in use, it sits under the
/// active artboard and offers Draw (Pencil) and Place Image.
/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the
/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]).
pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
@@ -83,13 +121,15 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
// Text added with a Type tool shows its formatting controls, as the help page describes
// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
let typing = type_tool_on_type(app);
if !(on_artboards || typing || selection_tool_with_selection(app)) {
// A blank canvas offers Draw (Pencil) and Place Image, as the same help page describes.
let blank = !on_artboards && blank_canvas(app);
if !(on_artboards || typing || blank || selection_tool_with_selection(app)) {
return;
}
// Selected type has the font controls too, as the help page describes
// (<https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/font-options-overview.html>).
let fonts = task_bar_shows_type(app);
let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return };
let Some(anchor) = bar_anchor(app, xf, on_artboards, blank) else { return };
let Some(st) = app.session.active() else { return };
let n = st.selection.len();
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id));
@@ -137,6 +177,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none()));
}
items.push((tl!("Export"), "share-2", "file.exportForScreens", none()));
} else if blank {
// Draw (Pencil) switches to the Pencil tool; Place Image picks files for the Place dialog.
items.push((tl!("Draw (Pencil)"), "pencil", "tool.select", json!({"tool": "pencil"})));
items.push((tl!("Place Image"), "image", "file.place", none()));
} else if !typing {
// The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines
// and the type conversions included.
@@ -234,27 +278,20 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let paints = chip_paints(app, ui.ctx());
let colored = has_colors(&st.doc, &st.selection.objects);
let doc = st.uid;
// Under the selection's lowest corner on screen, centered on the selection (the view may be
// rotated).
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
let bounds = xf.rect.shrink(8.0);
// The canvas's width less the frame's margins and stroke.
let room = bounds.width() - BAR_FRAME;
// On a canvas too narrow for the type controls at their narrowest, the bar leaves them out.
let fonts = fonts && room >= crate::panels::character::task_bar_min_room();
// The selected objects' Fill, Stroke, Recolor Artwork (when they have colours to recolour) and
// Lock (a basic shape's properties instead), left out with the Artboard tool. A Type tool's bar
// has the Fill alone.
let chips: Vec<Chip> = [
(!on_artboards, Chip::Fill),
(!on_artboards && !typing, Chip::Stroke),
(!on_artboards && !typing && colored, Chip::Recolor),
(!on_artboards && !typing, Chip::Lock),
]
.into_iter()
.filter_map(|(shown, chip)| shown.then_some(chip))
.collect();
// Lock (a basic shape's properties instead), left out with the Artboard tool and on a blank
// canvas, which have no selected objects. A Type tool's bar has the Fill alone.
let objects = !on_artboards && !blank;
let chips: Vec<Chip> =
[(objects, Chip::Fill), (objects && !typing, Chip::Stroke), (objects && !typing && colored, Chip::Recolor), (objects && !typing, Chip::Lock)]
.into_iter()
.filter_map(|(shown, chip)| shown.then_some(chip))
.collect();
let widths: Vec<f32> = items.iter().map(|(label, ..)| action_width(ui, label)).collect();
let font_w = fonts.then(|| {
let w = crate::panels::character::TASK_BAR_WIDTHS;
+2 -2
View File
@@ -10333,7 +10333,7 @@ fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String
/// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers
/// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the
/// selected objects' actions. With the Selection tool it offers the objects' actions again, and
/// with nothing selected it doesn't show.
/// with nothing in the document the blank canvas actions.
#[test]
fn the_task_bar_offers_the_artboard_tools_actions() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -10341,7 +10341,7 @@ fn the_task_bar_offers_the_artboard_tools_actions() {
app.select_tool("selection");
let ctx = egui::Context::default();
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar");
assert_eq!(names(&mut app), ["Draw (Pencil)", "Place Image"], "a blank canvas");
app.select_tool("artboard");
crate::panels::artboards::select(&mut app, 1);
+141 -52
View File
@@ -30,7 +30,8 @@ pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
/// What the Control bar and the Properties panel offer for selected anchors ([`anchor_controls`]):
/// "Convert:" corner or smooth, then "Anchors:" remove, connect (Join) and cut. Each group is a
/// row of its own: inline in the Control bar, one under the other in a panel.
/// row of its own: inline in the Control bar, one under the other in a panel. In the Control bar's
/// row, a group shows only while the row has room for its label and all its buttons.
pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorControls) {
let t = Tokens::get(ui.ctx());
let mut run = None;
@@ -56,9 +57,14 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon
AnchorControls::Convert => 1,
AnchorControls::All => groups.len(),
};
let in_row = ui.layout().main_dir() == egui::Direction::LeftToRight;
for (label, buttons) in groups.into_iter().take(shown) {
let text = egui::RichText::new(label).size(12.0).color(t.text);
if in_row && ui.available_width() < row_width(ui, &[widgets::text_width(ui, text.clone())]) + buttons_width(ui, buttons.len()) {
break;
}
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label).size(12.0).color(t.text));
ui.label(text);
for &(icon, tip, id, to) in buttons {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
run = Some((id, to.map_or_else(|| json!({}), |to| json!({ "to": to }))));
@@ -78,6 +84,12 @@ pub(super) const ISOLATION_BUTTON: &str = "cb-isolation";
/// less, the menu is left out so those controls still fit the narrowest window.
const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0;
/// The width of [`snapping_menu`]'s button: its label, as a button draws it, and the button's padding.
fn snapping_width(ui: &Ui) -> f32 {
let label = egui::WidgetText::from(tl!("Snapping")).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
label.size().x + 2.0 * ui.spacing().button_padding.x
}
/// The Control bar's snapping quick access; every change uses its existing command, and Pixel
/// Snapping Options… opens the dialog of `view.snapToPixel.options`.
fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -115,9 +127,10 @@ fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
/// while a symbol's art is the isolated layer. Otherwise, with one object selected that
/// [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which also isolates a
/// path inside a group that Direct Selection selected. An envelope's contents get no button: the
/// envelope's Edit Envelope and Edit Contents buttons show instead.
/// envelope's Edit Envelope and Edit Contents buttons show instead. It is left out when the bar has
/// no room left for it.
fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else { return };
let Some(st) = app.session.active().filter(|_| ui.available_width() >= BUTTON) else { return };
let (icon, tip, id) = if st.isolation.is_some() {
if st.doc.pattern_edit.is_some() {
return;
@@ -151,9 +164,9 @@ const GLOBAL_EDIT_OPTIONS_WIDTH: f32 = 24.0;
/// It opens the Properties panel's Global Edit Options, which set the same session options, and is
/// off and closed while Global Edit is on, as that panel's dropdown is. It sits after Recolor
/// Artwork, ahead of the X/Y/W/H fields that the bar leaves out when it runs short of room, and is
/// left out only when no room is left for it.
fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < GLOBAL_EDIT_OPTIONS_WIDTH {
/// left out when the room left, less `reserve` ([`transform_reserve`]), cannot hold it.
fn global_edit_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
if !crate::menus::enabled(app, "select.globalEdit") || ui.available_width() < row_width(ui, &[GLOBAL_EDIT_OPTIONS_WIDTH]) + reserve {
return;
}
let on = app.session.active().is_some_and(|d| d.global_edit.is_some());
@@ -204,8 +217,27 @@ fn buttons_width(ui: &Ui, n: usize) -> f32 {
(BUTTON + ui.spacing().item_spacing.x) * n as f32
}
/// The room controls `widths` wide take side by side in the bar's row, with the spacing after each.
fn row_width(ui: &Ui, widths: &[f32]) -> f32 {
widths.iter().map(|w| w + ui.spacing().item_spacing.x).sum()
}
/// The size of the Control bar's Fill, Stroke and Graphic Style chips.
const PAINT_CHIP: f32 = 22.0;
/// The width each of those chips takes with the chevron after it.
const CHIP_WIDTH: f32 = PAINT_CHIP + 16.0;
/// The widths of the Control bar's stroke weight and Opacity fields.
const WEIGHT_FIELD_WIDTH: f32 = 100.0;
const OPACITY_FIELD_WIDTH: f32 = 56.0;
/// The separator that ends a group of the Control bar's controls, as [`group_end_width`] measures it.
/// It is left out when the bar has no room left for it.
fn group_end(ui: &mut Ui) {
if ui.available_width() < group_end_width(ui) {
return;
}
ui.add_space(GROUP_GAP);
boundary(ui, separator);
}
@@ -256,8 +288,11 @@ fn boundary(ui: &mut Ui, draw: impl FnOnce(&mut Ui)) {
}
/// The separator that ends a group of the Control bar's controls, drawn as `ui.separator()` draws
/// one, and left out when no control follows it ([`boundary`]).
/// one, and left out when no control follows it ([`boundary`]) or the row has no room left for it.
pub(crate) fn group_separator(ui: &mut Ui) {
if ui.layout().main_dir() == egui::Direction::LeftToRight && ui.available_width() < separator_width(ui) {
return;
}
boundary(ui, |ui| {
ui.separator();
});
@@ -298,20 +333,30 @@ fn end_width(app: &VectorcraftApp, ui: &Ui, nothing_selected: bool) -> f32 {
}
}
/// The room the Control bar's Fill and Stroke, Opacity, Graphic Style and Character groups leave
/// for the Transform link: the link and the gap and separator ahead of it, while something is
/// `selected` and the panel menu has the Transform group on, or none. Those groups show each control
/// only while this room is left after it, so the bar leaves them out before the link.
fn transform_reserve(app: &VectorcraftApp, ui: &Ui, selected: bool) -> f32 {
if selected && group_on(app, "transform") { group_end_width(ui) + crate::panels::transform::link_width(ui) } else { 0.0 }
}
/// Draws the Control bar's groups between its Character controls and the Transform link, and
/// returns whether the Transform link and X/Y/W/H follow (false: nothing is selected, and Document
/// Setup and Preferences, drawn here, end the bar, or the Transform group is off). The groups are
/// the Paragraph section and area type's vertical alignment (#3759), then the Align buttons, the
/// Align To menu and, for two or more objects, the Distribute buttons (a single object keeps the bar
/// as it was); each shows only while the end of the bar keeps its room. Groups the panel menu has
/// off are left out, and take no room.
fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize) -> bool {
/// off are left out, and take no room. Without `room` (the Character group had none), all of these
/// groups are left out, so the bar trims them from its right end before the Character group.
fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize, room: bool) -> bool {
let nothing = objects == 0;
let end = end_width(app, ui, nothing);
if group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) {
if room && group_on(app, "paragraph") && crate::panels::paragraph::control_bar(app, ui, BUTTON, |ui| group_end_width(ui) + end) {
group_end(ui);
}
if group_on(app, "verticalAlignment")
if room
&& group_on(app, "verticalAlignment")
&& crate::panels::properties::vertical_align::shown(app).is_some()
&& ui.available_width() >= buttons_width(ui, 4) + group_end_width(ui) + end
&& crate::panels::properties::vertical_align::buttons(app, ui, BUTTON)
@@ -319,11 +364,13 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
group_end(ui);
}
if nothing {
// Each button shows only while the bar has room left for it, so Preferences can show
// when Document Setup did not fit.
if group_on(app, "documentOptions") {
if widgets::flat_button(ui, tl!("Document Setup"), DOCUMENT_SETUP_WIDTH).clicked() {
if ui.available_width() >= DOCUMENT_SETUP_WIDTH && widgets::flat_button(ui, tl!("Document Setup"), DOCUMENT_SETUP_WIDTH).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() {
if ui.available_width() >= PREFERENCES_WIDTH && widgets::flat_button(ui, tl!("Preferences"), PREFERENCES_WIDTH).clicked() {
app.run("edit.preferences", json!({})).ok();
}
}
@@ -331,7 +378,7 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize
}
let transform = group_on(app, "transform");
let end = if transform { separator_width(ui) + end } else { 0.0 };
if group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end {
if room && group_on(app, "alignDistribute") && ui.available_width() >= buttons_width(ui, 7) + end {
crate::panels::align::align_buttons(app, ui, BUTTON);
if ui.available_width() >= crate::panels::align::align_to_menu_width(BUTTON) + ui.spacing().item_spacing.x + end {
crate::panels::align::align_to_menu(app, ui, BUTTON);
@@ -545,55 +592,80 @@ fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
/// The Control bar's Fill and Stroke group: the fill and stroke chips, the Stroke link, the weight
/// and the width profile, and the separator after them while a control follows ([`group_separator`]).
fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui) {
/// Short of room, the bar leaves out the width profile first, then the Stroke link with the weight,
/// then the two chips; each shows only while the bar has room for all of it and `reserve` after it
/// ([`transform_reserve`]).
fn fill_and_stroke(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
if ui.available_width() < row_width(ui, &[CHIP_WIDTH, CHIP_WIDTH]) + reserve {
return;
}
let shown_stroke = crate::panels::current_stroke(app);
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
crate::panels::paint_chip(app, ui, false, 22.0, true);
crate::panels::paint_chip(app, ui, true, 22.0, true);
crate::panels::paint_chip(app, ui, false, PAINT_CHIP, true);
crate::panels::paint_chip(app, ui, true, PAINT_CHIP, true);
// The link opens the Stroke panel as a popover under it; then the weight spinner
// (with presets) and the width profile.
stroke_panel::link(app, ui, tl!("Stroke:"));
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
app.run("stroke.set", json!({"profile": id})).ok();
let link = widgets::text_width(ui, stroke_panel::link_text(ui, tl!("Stroke:")));
if ui.available_width() >= row_width(ui, &[link, WEIGHT_FIELD_WIDTH]) + reserve {
stroke_panel::link(app, ui, tl!("Stroke:"));
stroke_panel::weight_field(app, ui, "cb-stroke", weight, WEIGHT_FIELD_WIDTH);
if ui.available_width() >= row_width(ui, &[stroke_panel::PROFILE_DROPDOWN_WIDTH]) + reserve
&& let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref()))
{
app.run("stroke.set", json!({"profile": id})).ok();
}
}
ui.add_space(4.0);
group_separator(ui);
group_end(ui);
}
/// The Control bar's Opacity group (its link and field, showing `opacity`) and, with something
/// `selected`, its Graphic Style group (Style, Recolor Artwork and Global Edit Options), each while
/// the panel menu has it on, and the separator after them while a control follows.
fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool) {
/// the panel menu has it on, and the separator after them while a control follows. Short of room,
/// the bar leaves out Global Edit Options first, then Recolor Artwork, then the Style link with its
/// chip. The Opacity link with its field and the Style link with its chip each show only while the
/// bar has room for all of it and `reserve` after it ([`transform_reserve`]), so the narrower Style
/// part can show without the Opacity part.
fn opacity_and_style(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, opacity: f32, selected: bool, reserve: f32) {
let (opacity_on, style_on) = (group_on(app, "opacity"), group_on(app, "graphicStyle") && selected);
let mut drawn = false;
if opacity_on {
// A click opens the Transparency panel; a drag scrubs the field (#4088).
if crate::scrub::link_label(ui, egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
&& selected
{
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
let text = egui::RichText::new(tl!("Opacity:")).size(12.0).color(t.text).underline();
if ui.available_width() >= row_width(ui, &[widgets::text_width(ui, text.clone()), OPACITY_FIELD_WIDTH]) + reserve {
// A click opens the Transparency panel; a drag scrubs the field (#4088).
if crate::scrub::link_label(ui, text).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, OPACITY_FIELD_WIDTH)
&& selected
{
app.run("transparency.set", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
}
drawn = true;
}
}
if style_on {
let text = egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline();
if style_on && ui.available_width() >= GROUP_GAP + row_width(ui, &[widgets::text_width(ui, text.clone()), CHIP_WIDTH]) + reserve {
// Style picker: the selection's graphic style; its menu applies another.
ui.add_space(4.0);
if ui.link(egui::RichText::new(tl!("Style:")).size(12.0).color(t.text).underline()).clicked() {
ui.add_space(GROUP_GAP);
if ui.link(text).clicked() {
app.ui.open_panel = Some("graphicStyles".into());
}
let resp = widgets::chip_button_named(ui, 22.0, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r));
let resp =
widgets::chip_button_named(ui, PAINT_CHIP, true, tl!("Graphic Style"), |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r));
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
// Recolor Artwork, when the selection has colours to recolour.
let colored = app.session.active().is_some_and(|st| crate::canvas::has_colors(&st.doc, &st.selection.objects));
if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 24.0).clicked() {
if colored
&& ui.available_width() >= buttons_width(ui, 1) + reserve
&& widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, BUTTON).clicked()
{
crate::menus::invoke(app, "ui.recolorDialog", json!({}));
}
global_edit_options(app, ui);
global_edit_options(app, ui, reserve);
drawn = true;
}
if opacity_on || style_on {
if drawn {
group_separator(ui);
}
}
@@ -709,11 +781,16 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
}
gripper(app, ui, t, bar, dock_room);
if app.session.active().is_none() {
ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim));
let text = egui::RichText::new(tl!("No Document")).color(t.text_dim);
if ui.available_width() >= widgets::text_width(ui, text.clone()) {
ui.label(text);
}
return;
}
// The Artboard tool's controls fill the narrowest window, so they keep it to themselves there.
if group_on(app, "snapping") && (app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH) {
// Any other tool's bar shows the menu while it has room for it.
let snapping_room = if app.session.tool_id() == "artboard" { ARTBOARD_SNAPPING_WIDTH } else { snapping_width(ui) };
if group_on(app, "snapping") && ui.available_width() >= snapping_room {
snapping_menu(app, ui);
}
let Some(st) = app.session.active() else { return };
@@ -739,7 +816,10 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
_ => tl!(crate::panels::appearance::object_label(app)),
};
let has_first = first.is_some();
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
let label = egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text);
if ui.available_width() >= widgets::text_width(ui, label.clone()) {
ui.label(label);
}
// With the Artboard tool in use, the bar shows the active artboard's controls in place
// of the object controls.
let (artboard_options, transform) = (group_on(app, "artboardOptions"), group_on(app, "transform"));
@@ -755,7 +835,9 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
if object_options {
isolation_button(app, ui);
}
ui.add_space(6.0);
if ui.available_width() >= 6.0 {
ui.add_space(6.0);
}
// An image or an Image Trace object shows its own controls in place of Fill and Stroke,
// and leaves Fill and Stroke out while the Image Options group is off too.
let image = if group_on(app, "imageOptions") {
@@ -771,19 +853,26 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
crate::panels::symbols::control_bar(app, ui);
}
let opacity = if has_first { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
let reserve = transform_reserve(app, ui, !sel.is_empty());
if !image && group_on(app, "fillStroke") {
fill_and_stroke(app, ui);
fill_and_stroke(app, ui, reserve);
}
opacity_and_style(app, ui, t, opacity, !sel.is_empty());
if group_on(app, "character") {
crate::panels::character::control_bar(app, ui);
}
if !groups_before_transform(app, ui, sel.len()) {
opacity_and_style(app, ui, t, opacity, !sel.is_empty(), reserve);
// The Character group shows whole, with the Transform link's room left after it, or not at
// all; left out, it leaves out the groups between it and the link too.
let room = !group_on(app, "character") || crate::panels::character::control_bar(app, ui, reserve);
if !groups_before_transform(app, ui, sel.len(), room) {
erase_trailing(ui);
return;
}
// The Transform link opens the whole Transform panel (reference point, rotate,
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room.
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room. The
// groups from Fill and Stroke on keep its room, but the controls ahead of them can
// still take it.
if ui.available_width() < crate::panels::transform::link_width(ui) {
erase_trailing(ui);
return;
}
crate::panels::transform::link(app, ui);
// The bounding box, rotated with rotated objects: its centre and its own sides.
if let Some(b) = app.selection_box()
+121
View File
@@ -3037,6 +3037,127 @@ fn no_separator_is_left_at_the_end_of_the_control_bar() {
assert!(stray.is_empty(), "a separator with nothing after it: {stray:#?}");
}
/// The shapes of the Control bar's controls drawn in window `screen` that reach past the left edge
/// of its panel menu button, or past the room a floating bar clips its controls to, or `None` when
/// the bar leaves the button out.
fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, screen: egui::Rect) -> Option<Vec<egui::Rect>> {
control_bar_frame_in(app, ctx, screen, vec![]);
let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default();
let menu_left = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds())?.x0 as f32;
// The bar's frame spans the whole bar, the panel menu button included.
let bar_width = if app.ui.control_bar_floating { screen.width().min(800.0) } else { screen.width() };
let shapes = out.shapes.iter().filter_map(|cs| {
let r = cs.shape.visual_bounding_rect();
let control = r.is_finite() && cs.clip_rect.intersects(r) && r.width() < bar_width - 4.0 && r.min.x < menu_left - 0.5;
let clipped = cs.clip_rect.right() < menu_left && r.right() > cs.clip_rect.right() + 0.5;
(control && (r.right() > menu_left + 0.5 || clipped)).then_some(r)
});
Some(shapes.collect())
}
/// In a narrow window, the Control bar leaves out a control it has no room for, Fill and Stroke,
/// Opacity and Character included: every control it shows is drawn whole, left of the panel menu button, which
/// shows in every width, with the bar docked to the top or the bottom or floating.
#[test]
fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() {
use crate::tests_removeanchors::{control_frame, has};
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let mut faults = vec![];
let mut check = |app: &mut crate::VectorcraftApp, case: &str| {
for (bottom, floating) in [(false, false), (true, false), (false, true)] {
(app.ui.control_bar_bottom, app.ui.control_bar_floating) = (bottom, floating);
for w in (150..=1500).step_by(9) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
match control_bar_overflow(app, &ctx, screen) {
None => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: no panel menu")),
Some(over) if !over.is_empty() => faults.push(format!("{case}, bottom {bottom}, floating {floating}, {w} wide: {over:?}")),
Some(_) => continue,
}
break;
}
}
(app.ui.control_bar_bottom, app.ui.control_bar_floating) = (false, false);
};
app.run("select.set", json!({ "ids": [id] })).unwrap();
let bar = control_frame(&mut app, &ctx, vec![]);
assert!(has(&bar, "Stroke:") && has(&bar, "Opacity:"), "Fill and Stroke and Opacity show in a wide window: {bar:?}");
check(&mut app, "a rectangle selected");
app.select_tool("directSelection");
app.run("select.anchors", json!({"id": id, "anchors": [[0, 0]]})).unwrap();
check(&mut app, "an anchor selected");
app.select_tool("selection");
let text = app.run("text.create", json!({"x": 20, "y": 120, "text": "Point"})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [text] })).unwrap();
let bar = control_frame(&mut app, &ctx, vec![]);
assert!(has(&bar, "Character:"), "the Character group shows in a wide window: {bar:?}");
check(&mut app, "type selected");
app.run("select.set", json!({ "ids": [] })).unwrap();
check(&mut app, "nothing selected");
assert!(faults.is_empty(), "a control drawn past the bar's room: {faults:#?}");
}
/// With Opacity and Graphic Style on, a bar too narrow for the Opacity link and its field still
/// shows the narrower Style link and its chip when it has room for them.
#[test]
fn a_narrow_control_bar_shows_the_style_link_without_the_opacity_field() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [id] })).unwrap();
app.run("window.control.options", json!({"group": "fillStroke", "visible": false})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name));
let (mut both, mut style_only) = (false, false);
for w in (150..=900).step_by(2) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let (opacity, style) = (shows(&nodes, "Opacity:"), shows(&nodes, "Style:"));
both |= opacity && style;
style_only |= style && !opacity;
}
assert!(both, "a wide bar shows the Opacity and Style links");
assert!(style_only, "some width has room for the Style link and its chip but not the Opacity link and its field");
}
/// With nothing selected, a bar too narrow for the Document Setup button still shows the narrower
/// Preferences button when it has room for it.
#[test]
fn a_narrow_control_bar_shows_preferences_without_document_setup() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
app.run("select.set", json!({ "ids": [] })).unwrap();
for group in ["fillStroke", "opacity", "graphicStyle"] {
app.run("window.control.options", json!({"group": group, "visible": false})).unwrap();
}
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name));
let (mut both, mut preferences_only) = (false, false);
for w in (150..=900).step_by(2) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let (setup, preferences) = (shows(&nodes, "Document Setup"), shows(&nodes, "Preferences"));
both |= setup && preferences;
preferences_only |= preferences && !setup;
}
assert!(both, "a wide bar shows Document Setup and Preferences");
assert!(preferences_only, "some width has room for Preferences but not Document Setup");
}
/// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps
/// its Anchor Point label.
#[test]
+3 -1
View File
@@ -719,7 +719,8 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
/// After a dialog closes, and each frame: a Missing Fonts dialog that another dialog replaced waits
/// for its turn again, the search a Missing Fonts or Find Linked Files dialog started stops once
/// that dialog is gone, and with no dialog open the choice about a stopped plug-in that another
/// dialog replaced opens again, else the next document's Missing Fonts dialog (one dialog at a
/// dialog replaced opens again, else the questions about the next restored document's linked files
/// ([`crate::recovery::ask_next`]), else the next document's Missing Fonts dialog (one dialog at a
/// time).
pub(crate) fn settle(app: &mut VectorcraftApp) {
crate::placing::settle_swatch_paste(app);
@@ -727,6 +728,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) {
missing_fonts::stop_when_closed(app);
find_links::stop_when_closed(app);
safe_mode::reopen(app);
crate::recovery::ask_next(app);
missing_fonts::open_next(app);
}
+53 -9
View File
@@ -54,14 +54,7 @@ fn after_open_for(app: &mut VectorcraftApp, r: &Value, notes: Vec<String>, web:
let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
let notice = if missing.is_empty() || web {
let unreadable = (!missing.is_empty()).then(|| {
crate::i18n::msg_n(
missing.len() as u64,
"{n} linked file can't be read here: its preview shows",
"{n} linked files can't be read here: their previews show",
&[],
)
});
let unreadable = (!missing.is_empty()).then(|| unreadable_status(missing.len()));
ask_update(app, modified).or(unreadable)
} else {
open(app, missing, modified, &notes);
@@ -71,6 +64,40 @@ fn after_open_for(app: &mut VectorcraftApp, r: &Value, notes: Vec<String>, web:
super::settle(app);
}
/// The status bar's line for `n` linked files the web can't read.
fn unreadable_status(n: usize) -> String {
crate::i18n::msg_n(
n as u64,
"{n} linked file can't be read here: its preview shows",
"{n} linked files can't be read here: their previews show",
&[],
)
}
/// What the linked files of a document that opened (`document.open`'s result `r`) need, without
/// asking yet → the status bar's lines about them, the missing files to ask about and the ids of
/// the modified images to offer for update ([`ask_next`]). The web reads no linked files, so the
/// missing ones get a line there; without Update Links: Ask When Modified the modified ones get a
/// line instead of the offer.
pub(crate) fn questions(app: &VectorcraftApp, r: &Value) -> (Vec<String>, Vec<Value>, Vec<Value>) {
questions_for(app, r, cfg!(target_arch = "wasm32"))
}
fn questions_for(app: &VectorcraftApp, r: &Value, web: bool) -> (Vec<String>, Vec<Value>, Vec<Value>) {
let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
let (mut missing, mut modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
let mut lines = vec![];
if web && !missing.is_empty() {
lines.push(unreadable_status(missing.len()));
missing.clear();
}
if !modified.is_empty() && app.session.prefs.update_links != "askWhenModified" {
lines.push(changed_on_disk_status(modified.len()));
modified.clear();
}
(lines, missing, modified)
}
fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>, notes: &[String]) {
let fields = json!({ "missing": missing, "modified": modified, "notes": notes, "applyToAll": false, "path": "" });
app.ui.dialog = Some(Dialog::new(KIND, fields));
@@ -85,7 +112,7 @@ pub(super) fn notes(d: &Dialog) -> Vec<String> {
/// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for
/// update. A line about the modified files is followed by the open's `notes`
/// ([`crate::io::show_opened`]).
pub(super) fn ask_next(app: &mut VectorcraftApp, rest: Vec<Value>, modified: Vec<Value>, notes: Vec<String>) {
pub(crate) fn ask_next(app: &mut VectorcraftApp, rest: Vec<Value>, modified: Vec<Value>, notes: Vec<String>) {
if rest.is_empty() {
app.ui.dialog = None;
if let Some(notice) = ask_update(app, modified) {
@@ -309,6 +336,23 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
/// A restored document's linked files: the web gets a line about the missing ones, and
/// without Update Links: Ask When Modified the modified ones get a line; the rest are asked
/// about.
#[test]
fn the_questions_about_linked_files_wait_or_become_lines() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let r = json!({ "missingLinks": [{"name": "a.png", "path": "/x/a.png", "ids": [1]}], "modifiedLinks": [{"ids": [2]}, {"ids": [3]}] });
app.session.prefs.update_links = "askWhenModified".into();
assert_eq!(questions_for(&app, &r, false), (vec![], r["missingLinks"].as_array().unwrap().clone(), vec![json!(2), json!(3)]));
let changed = "2 linked images changed on disk: Update Links shows the new versions".to_string();
app.session.prefs.update_links = "manually".into();
assert_eq!(questions_for(&app, &r, false), (vec![changed.clone()], r["missingLinks"].as_array().unwrap().clone(), vec![]));
let unreadable = "1 linked file can't be read here: its preview shows".to_string();
assert_eq!(questions_for(&app, &r, true), (vec![unreadable, changed], vec![], vec![]));
assert!(app.ui.dialog.is_none(), "nothing is asked yet");
}
/// The question about modified linked files uses the form the language's plural rule picks for
/// the count.
#[test]
+5 -5
View File
@@ -253,7 +253,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
// Version first; the legacy version takes only its own options.
let legacy = legacy_ai(d);
let mut rows: Vec<&FormatOption> = shown(f).filter(|o| LEGACY_ONLY.contains(&o.name) == legacy || o.name == "aiVersion").collect();
let mut rows: Vec<&FormatOption> = shown(f).filter(|o| fileio::file_reads(f, legacy, o.name)).collect();
// EPS's PostScript level comes last, as in Export's EPS Options.
rows.sort_by_key(|o| (o.name != "aiVersion", f.id == "eps" && o.name == "level"));
for o in rows {
@@ -269,10 +269,6 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// The `.ai` options only the legacy version takes (and the only ones it takes, besides the
/// version).
const LEGACY_ONLY: &[&str] = &["transparentImages", "hiddenObjects", "outlineText"];
/// Is a `.ai` file to be saved in the legacy version 7 (`aiVersion: "7"`)?
fn legacy_ai(d: &Dialog) -> bool {
d.str("format") == "ai" && d.str("aiVersion").trim() == "7"
@@ -615,9 +611,13 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
return Err("name the file".into());
}
let path = if d.bool("__pick") { with_extension(path.trim(), f) } else { path };
// Only the options the file reads: a `.ai` file's version takes its own (when the document's
// own file is a `.ai`, the save remembers the others as they were).
let legacy = legacy_ai(d);
let mut options: Map<String, Value> = f
.options
.iter()
.filter(|o| fileio::file_reads(f, legacy, o.name))
.filter_map(|o| d.fields.get(o.name).filter(|v| !v.is_null() && v.as_str() != Some("")).map(|v| (o.name.to_string(), v.clone())))
.collect();
if uncompressed(d) {
@@ -125,6 +125,33 @@ fn save_as_ai_version_7_writes_a_file_per_artboard() {
assert_eq!((st.save_options["hiddenObjects"].clone(), st.save_options["outlineText"].clone()), (json!("visible"), json!(true)));
}
/// Save Options sends a `.ai` save only the options its version reads, so a person's save gets no
/// note about an option the file doesn't read, and the other version's choices stay as the last
/// save left them.
#[test]
fn ai_save_options_send_only_the_options_the_version_reads() {
let (mut app, _, _) = desktop("art.ai");
for (round, legacy) in [true, false, true].into_iter().enumerate() {
assert_eq!(app.run("file.saveAs", json!({})).unwrap()["pending"], KIND);
frame(&mut app, false);
match round {
1 => assert!(dialog(&app).bool("outlineText"), "the legacy choice is shown as last saved"),
2 => assert!(!dialog(&app).bool("editingData"), "the PDF-compatible choice is shown as last saved"),
_ => {}
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("aiVersion".into(), json!(if legacy { "7" } else { "pdf" }));
let (key, value) = if legacy { ("outlineText", true) } else { ("editingData", false) };
d.fields.insert(key.into(), json!(value));
let d = d.clone();
app.ui.dialog = None;
let r = confirm(&mut app, &d).unwrap();
assert!(!r["warnings"].to_string().contains("was not used"), "round {round}: {r}");
}
let st = app.session.active().unwrap();
assert_eq!((st.save_options["outlineText"].clone(), st.save_options["editingData"].clone()), (json!(true), json!(false)));
}
/// An Illustrator file opened, then saved as `.ai`: the options dialog stays open through the
/// Enter that closed the save panel, and only an Enter after it is let go confirms it.
#[test]
@@ -88,6 +88,39 @@ fn opens_on_defaults_and_draws_every_view() {
assert_eq!((spec(export_for_screens::KIND).ok_label.unwrap())(&app), "Export Artboard");
}
/// The dialog's own request, with a row of every format its Format menu offers and every format's
/// settings as it opens, gets no note about an option a file doesn't read.
#[test]
fn the_dialogs_own_rows_and_settings_get_no_unused_option_notes() {
let (mut app, _) = app(1, true);
open(&mut app);
let rows: Vec<Value> = [
json!({"format": "png"}),
json!({"format": "png8"}),
json!({"format": "jpg", "quality": 100}),
json!({"format": "webp"}),
json!({"format": "gif"}),
json!({"format": "tiff"}),
json!({"format": "svg"}),
json!({"format": "pdf"}),
json!({"format": "pdf", "marks": {"trim": true}, "suffix": "-print"}),
json!({"format": "eps"}),
json!({"format": "ai"}),
json!({"format": "vectorcraft"}),
]
.into_iter()
.map(|mut r| {
r["scale"] = json!("1x");
r
})
.collect();
set(&mut app, "formats", json!(rows));
let p = export_for_screens::params(&app, app.ui.dialog.as_ref().unwrap()).unwrap();
let r = app.session.execute("document.exportForScreens", &p).unwrap();
let unused: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("was not used")).collect();
assert!(unused.is_empty(), "{unused:?}");
}
#[test]
fn checked_artboards_read_as_a_range() {
let (mut app, _) = app(5, false);
+13 -6
View File
@@ -1,8 +1,10 @@
//! A selection dragged across document tabs remains editable and leaves its source intact.
use std::sync::Arc;
use egui::{Context, Id, Response};
use serde_json::json;
use vectorcraft_doc::NodeId;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::Point;
use crate::{VectorcraftApp, canvas, widgets};
@@ -19,7 +21,11 @@ struct Origin {
struct Artwork {
origin: Origin,
ids: Vec<NodeId>,
revision: u64,
/// The source document when the drag left it. The drag ends when the source's content differs
/// from it ([`vectorcraft_engine::DocState::same_content`]). A redraw that raises its revision
/// (another document's assigned color profiles brought to the front) and a change that is not
/// an edit (its type measured again) keep it.
source: Arc<Document>,
target: u64,
}
@@ -75,7 +81,7 @@ pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) {
}
});
if let Some(art) = egui::DragAndDrop::payload::<Artwork>(ctx) {
let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.revision == art.revision)
let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.same_content(&art.source))
&& app.session.active().is_some_and(|d| d.uid == art.target);
let cancel = ctx.input(|i| {
!i.focused
@@ -143,13 +149,14 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, win: crate::d
return true;
}
// Alt-drag previews use temporary duplicate ids; only the restored originals travel.
let ids = app.session.active().map(|d| d.selection.objects.clone()).unwrap_or_default();
let Some((ids, source)) = app.session.active().map(|d| (d.selection.objects.clone(), d.doc.clone())) else {
return true;
};
if ids.is_empty() {
return true;
}
canvas::end_document_drag(ctx);
let revision = app.session.active().map(|d| d.revision).unwrap_or_default();
egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, revision, target: uid });
egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, source, target: uid });
}
if transfer && let Some(art) = egui::DragAndDrop::payload::<Artwork>(ctx) {
let mut art = (*art).clone();
+6 -2
View File
@@ -1691,7 +1691,7 @@ pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
/// What the font menus preview on and apply to: the Type tool's selected range while it edits
/// text (the whole text when nothing is selected), else the selected text objects.
/// text (the whole text when nothing is selected), else every frame of each selected story.
pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) {
let previewing = crate::panels::pstate::<bool>(ctx, PREVIEWING);
match pick {
@@ -1791,7 +1791,11 @@ fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'s
}
("text.setRangeStyle", p)
}
None => ("text.setStyle", p),
None => {
// With the Selection tool: every frame of each selected story.
crate::panels::character::target_selected_stories(app, &mut p);
("text.setStyle", p)
}
}
}
+27 -5
View File
@@ -97,7 +97,7 @@ fn plural_polish(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 28] = [
pub static LANGUAGES: [LangInfo; 29] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
// Hindi shares the integer zero/one rule with French. Desktop UI fonts are loaded lazily
// from installed faces by ui_fonts; the web needs a Devanagari font in its font set.
@@ -109,6 +109,18 @@ pub static LANGUAGES: [LangInfo; 28] = [
complete_menus: true,
catalog: OnceLock::new(),
},
// Gujarati: menus and dialogs, with partial status/error message coverage and loanwords
// transliterated into Gujarati script; zero/one singular plurals. Gujarati text is drawn with an installed system font
// (Gujarati Sangam MN, Nirmala UI, Shruti) or a craft-fonts face tagged `Gujr`. Web selection
// requires a bundled Gujarati interface face; otherwise `gu-*` locales use the fallback.
LangInfo {
code: "gu",
name: "ગુજરાતી",
source: include_str!("i18n/gu.tsv"),
plural: plural_french,
complete_menus: true,
catalog: OnceLock::new(),
},
// Greek: the whole interface and the status and error messages, keeping the same names in
// English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here.
LangInfo {
@@ -355,6 +367,16 @@ pub static LANGUAGES: [LangInfo; 28] = [
];
impl LangInfo {
fn available(&self) -> bool {
self.available_with_fonts(cfg!(target_arch = "wasm32"), vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Gujr")))
}
fn available_with_fonts(&self, web: bool, gujarati_font: bool) -> bool {
// Browsers cannot use the native installed-font fallback. Do not offer a language
// whose text would be missing-glyph boxes, including when restoring a preference.
self.code != "gu" || !web || gujarati_font
}
fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| Catalog::parse(self.source))
}
@@ -387,7 +409,7 @@ impl Lang {
/// A language by its exact code.
pub fn from_code(code: &str) -> Option<Lang> {
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code) && l.available()).map(Lang)
}
/// Resolve the `interfaceLanguage` preference: a language code, or `auto` (and anything
@@ -396,12 +418,12 @@ impl Lang {
Lang::from_code(pref).unwrap_or_else(system_lang)
}
/// Every registered language.
/// Every language available in this build.
pub fn all() -> impl Iterator<Item = Lang> {
LANGUAGES.iter().map(Lang)
LANGUAGES.iter().filter(|l| l.available()).map(Lang)
}
/// Every registered language in the order a picker lists them: English first (the source
/// Every available language in the order a picker lists them: English first (the source
/// language and the fallback), then the rest by their own name with accents folded, so
/// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in
/// code point order: Greek, Cyrillic, Arabic, then the CJK languages.
+9 -4
View File
@@ -691,6 +691,8 @@ font-technology Variable Променливи
Crop Image Изрязване на изображение
Image Tools Инструменти за изображения
Remove Background… Премахване на фона…
Draw (Pencil) Рисуване (Молив)
Place Image Вмъкване на изображение
Remove Background Премахване на фона
Background Фон
Edge Color Цвят на ръба
@@ -4702,11 +4704,11 @@ axis Both И двете
@msg unknown effect `{id}` непознат ефект `{id}`
@msg unknown format непознат формат
@msg unknown format `{f}` (Save writes {_1}) непознат формат `{f}` (Запазването записва {_1})
@msg unknown freeform mode `{m}` (points, lines) непознат режим на свободна форма `{m}` (точки, линии)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) непознат вид градиент `{k}` (линеен, радиален, конусен, ромбовиден, свободна форма)
@msg unknown freeform mode `{m}` (points, lines) непознат режим на свободна форма `{m}` (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) непознат вид градиент `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` непознат акцент `{h}`
@msg unknown option `{path}.{k}` непозната опция `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (запазване, симулиране или отхвърляне)
@msg unknown overprints `{o}` (preserve, simulate or discard) непознати надписи `{o}` (preserve, simulate или discard)
@msg unknown panel `{raw}` непознат панел `{raw}`
@msg unknown preference `{key}` непозната настройка `{key}`
@msg Preferences imported. Настройките са импортирани.
@@ -4724,7 +4726,7 @@ axis Both И двете
@msg `{key}` must be an object `{key}` трябва да бъде обект
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` се изпълнява самостоятелно, а не като стъпка от пакет
@msg unknown preset (one of {_1}) непозната предварителна настройка (една от {_1})
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) непозната предварителна настройка `{name}` (висока, средна, ниска или запазена: вижте flattener.presets.list)
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) непозната предварителна настройка `{name}` (high, medium, low или запазена: вижте flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) непозната предварителна настройка `{name}` (вижте flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) непозната предварителна настройка `{name}` (вижте webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) непозната страна `{other}` (left|right|top|bottom)
@@ -5027,6 +5029,7 @@ effect Soft Меко
VectorCraft Title Bar on Every Desktop Заглавна лента на VectorCraft на всеки работен плот
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Докато е зададена, променливата на средата VECTORCRAFT_TITLEBAR избира заглавната лента.
System Title Bar takes precedence over this option while it is on. Докато е включена, „Заглавна лента на системата“ има предимство пред тази опция.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Докато „Заглавна лента на VectorCraft на всеки работен плот“ е изключена, този работен плот изчертава заглавната лента на прозореца независимо дали тази опция е включена.
Value Axis Ос на стойностите
On Left Side От лявата страна
On Right Side От дясната страна
@@ -6057,6 +6060,8 @@ axis Y Axis Y ос
Action Options… Опции на действието…
Action Options Опции на действието
Playback Options… Опции за възпроизвеждане…
Button Mode Режим с бутони
No actions. Няма действия.
Insert Stop… Вмъкване на спиране…
Insert Stop Вмъкване на спиране
Record Stop Записване на спиране
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -201,6 +201,8 @@
Crop Image Izreži sliku
Image Tools Alati za slike
Remove Background… Ukloni pozadinu…
Draw (Pencil) Crtaj (olovka)
Place Image Postavi sliku
Remove Background Ukloni pozadinu
Background Pozadina
Edge Color Boja ruba
@@ -982,6 +984,7 @@
VectorCraft Title Bar on Every Desktop Naslovna traka VectorCrafta na svakoj radnoj površini
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dok je varijabla okruženja VECTORCRAFT_TITLEBAR postavljena, ona bira naslovnu traku.
System Title Bar takes precedence over this option while it is on. Dok je uključena, Sistemska naslovna traka ima prednost pred ovom opcijom.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Dok je Naslovna traka VectorCrafta na svakoj radnoj površini isključena, ova radna površina crta naslovnu traku prozora bez obzira na to je li ova opcija uključena.
Exit Izlaz
Distort Izobliči
Diffuse Glow… Difuzni sjaj…
@@ -6099,6 +6102,8 @@ axis Y Axis Y osa
Action Options… Opcije akcije…
Action Options Opcije akcije
Playback Options… Opcije reprodukcije…
Button Mode Način dugmadi
No actions. Nema akcija.
Insert Stop… Umetni zaustavljanje…
Insert Stop Umetni zaustavljanje
Record Stop Snimi zaustavljanje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -1006,6 +1006,8 @@ antiAlias Sharp Nítid
Crop Image Retalla la imatge
Image Tools Eines d’imatge
Remove Background… Elimina el fons…
Draw (Pencil) Dibuixa (llapis)
Place Image Col·loca una imatge
Remove Background Elimina el fons
Background Fons
Edge Color Color de la vora
@@ -4095,6 +4097,8 @@ digits Malayalam Malaiàlam
Action Options… Opcions de l’acció…
Action Options Opcions de l’acció
Playback Options… Opcions de reproducció…
Button Mode Mode de botons
No actions. No hi ha accions.
Insert Stop… Insereix una aturada…
Insert Stop Insereix una aturada
Record Stop Enregistra una aturada
@@ -4613,6 +4617,7 @@ digits Malayalam Malaiàlam
VectorCraft Title Bar on Every Desktop Barra de títol de VectorCraft a tots els escriptoris
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mentre està definida, la variable d’entorn VECTORCRAFT_TITLEBAR determina la barra de títol.
System Title Bar takes precedence over this option while it is on. Mentre està activada, Barra de títol del sistema té prioritat sobre aquesta opció.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Mentre Barra de títol de VectorCraft a tots els escriptoris està desactivada, aquest escriptori dibuixa la barra de títol de la finestra tant si aquesta opció està activada com si no.
Tab Tabulació
Tab Stops Aturades de tabulació
Table Taula
@@ -5289,6 +5294,7 @@ digits Malayalam Malaiàlam
@msg `{key}` must be a #rrggbb colour `{key}` ha de ser un color #rrggbb
@msg `{key}` must be a number `{key}` ha de ser un nombre
@msg `{key}` must be a number not below 0 `{key}` ha de ser un nombre no inferior a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` han de ser quatre nombres no inferiors a 0: [tl, tr, br, bl]
@msg `{key}` must be a number, not {v} `{key}` ha de ser un nombre, no {v}
@msg `{key}` must be a string `{key}` ha de ser una cadena
@msg `{key}` must be a profile name or null `{key}` ha de ser un nom de perfil o null
Can't render this file because it contains an unexpected character in line 2 and column 73.
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -194,6 +194,8 @@
Crop Image Beskær billede
Image Tools Billedværktøjer
Remove Background… Fjern baggrund…
Draw (Pencil) Tegn (blyant)
Place Image Placér billede
Remove Background Fjern baggrund
Background Baggrund
Edge Color Kantfarve
@@ -924,6 +926,7 @@
VectorCraft Title Bar on Every Desktop VectorCrafts titellinje på alle skriveborde
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljøvariablen VECTORCRAFT_TITLEBAR vælger titellinjen, så længe den er angivet.
System Title Bar takes precedence over this option while it is on. Systemtitellinje har forrang for denne indstilling, så længe den er slået til.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Så længe VectorCrafts titellinje på alle skriveborde er slået fra, tegner dette skrivebord vinduets titellinje, uanset om denne indstilling er slået til eller fra.
Exit Afslut
Distort Forvræng
Diffuse Glow… Diffus glød…
@@ -4795,11 +4798,11 @@ imageTrace Outline Kontur
@msg unknown effect `{id}` ukendt effekt `{id}`
@msg unknown format ukendt format
@msg unknown format `{f}` (Save writes {_1}) ukendt format `{f}` (Gem skriver {_1})
@msg unknown freeform mode `{m}` (points, lines) ukendt freeform-tilstand `{m}` (punkter, linjer)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) ukendt gradienttype `{k}` (lineær, radial, konisk, diamant, freeform)
@msg unknown freeform mode `{m}` (points, lines) ukendt freeform-tilstand `{m}` (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) ukendt gradienttype `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` ukendt fremhævning `{h}`
@msg unknown option `{path}.{k}` ukendt indstilling `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) ukendt overprint `{o}` (bevar, simulér eller forkast)
@msg unknown overprints `{o}` (preserve, simulate or discard) ukendt overprint `{o}` (preserve, simulate eller discard)
@msg unknown panel `{raw}` ukendt panel `{raw}`
@msg unknown preference `{key}` ukendt præference `{key}`
@msg Preferences imported. Præferencerne er importeret.
@@ -4817,10 +4820,10 @@ imageTrace Outline Kontur
@msg `{key}` must be an object `{key}` skal være et objekt
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` køres alene, ikke som et trin i en batch
@msg unknown preset (one of {_1}) ukendt forudindstilling (én af {_1})
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) ukendt forudindstilling `{name}` (høj, mellem, lav eller en gemt: se flattener.presets.list)
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) ukendt forudindstilling `{name}` (high, medium, low eller en gemt: se flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) ukendt forudindstilling `{name}` (se flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) ukendt forudindstilling `{name}` (se webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) ukendt side `{other}` (venstre|højre|top|bund)
@msg unknown side `{other}` (left|right|top|bottom) ukendt side `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` ukendt værktøj `{tool}`
@msg unknown tool `{t}` ukendt værktøj `{t}`
@msg unknown tool or command `{key}` ukendt værktøj eller kommando `{key}`
@@ -6073,6 +6076,8 @@ axis Y Axis Y-akse
Action Options… Indstillinger for handling…
Action Options Indstillinger for handling
Playback Options… Afspilningsindstillinger…
Button Mode Knaptilstand
No actions. Ingen handlinger.
Insert Stop… Indsæt stop…
Insert Stop Indsæt stop
Record Stop Optag stop
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -684,6 +684,8 @@ font-technology Variable Variabel
Crop Image Bild beschneiden
Image Tools Bildwerkzeuge
Remove Background… Hintergrund entfernen…
Draw (Pencil) Zeichnen (Buntstift)
Place Image Bild platzieren
Remove Background Hintergrund entfernen
Background Hintergrund
Edge Color Randfarbe
@@ -4412,6 +4414,7 @@ theme Appearance Erscheinungsbild
@msg `select` must be true or false `select` muss true oder false sein
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` und `maxAnchors` müssen ganze Zahlen sein
@msg `{key}` must be a number not below 0 `{key}` muss eine Zahl nicht kleiner als 0 sein
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` muss aus vier Zahlen nicht kleiner als 0 bestehen: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` muss [x, y, width, height] sein: vier endliche Zahlen, Breite und Höhe nicht negativ
@msg `{min_key}` is greater than `{max_key}` `{min_key}` ist größer als `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` geben Sie mindestens einen der Parameter `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` an
@@ -5219,6 +5222,7 @@ theme Appearance Erscheinungsbild
VectorCraft Title Bar on Every Desktop VectorCraft-Titelleiste auf jedem Desktop
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Solange die Umgebungsvariable VECTORCRAFT_TITLEBAR gesetzt ist, bestimmt sie die Titelleiste.
System Title Bar takes precedence over this option while it is on. Solange Systemtitelleiste eingeschaltet ist, hat sie Vorrang vor dieser Option.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Solange VectorCraft-Titelleiste auf jedem Desktop ausgeschaltet ist, zeichnet dieser Desktop die Titelleiste des Fensters, ob diese Option ein- oder ausgeschaltet ist.
# Brush Options: Scatter, Art and Pattern brushes.
Scatter Brush Options Optionen für Spezialpinsel
Art Brush Options Optionen für Bildpinsel
@@ -6192,6 +6196,8 @@ axis Y Axis Y-Achse
Action Options… Aktionsoptionen…
Action Options Aktionsoptionen
Playback Options… Wiedergabeoptionen…
Button Mode Schaltflächenmodus
No actions. Keine Aktionen.
Insert Stop… Stopp einfügen…
Insert Stop Stopp einfügen
Record Stop Stopp aufzeichnen
Can't render this file because it is too large.
+6
View File
@@ -665,6 +665,8 @@ font-technology Variable Μεταβλητές
Crop Image Περικοπή εικόνας
Image Tools Εργαλεία εικόνας
Remove Background… Αφαίρεση φόντου…
Draw (Pencil) Σχεδίαση (μολύβι)
Place Image Τοποθέτηση εικόνας
Remove Background Αφαίρεση φόντου
Background Φόντο
Edge Color Χρώμα άκρης
@@ -2559,6 +2561,7 @@ axis Both Και τα δύο
@msg `select` must be true or false Το `select` πρέπει να είναι true ή false
@msg `minAnchors` and `maxAnchors` must be whole numbers Τα `minAnchors` και `maxAnchors` πρέπει να είναι ακέραιοι αριθμοί
@msg `{key}` must be a number not below 0 Το `{key}` πρέπει να είναι αριθμός όχι μικρότερος από 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] Το `{key}` πρέπει να είναι τέσσερις αριθμοί όχι μικρότεροι από 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative Το `{key}` πρέπει να είναι [x, y, width, height]: τέσσερις πεπερασμένοι αριθμοί, με πλάτος και ύψος όχι αρνητικά
@msg `{min_key}` is greater than `{max_key}` Το `{min_key}` είναι μεγαλύτερο από το `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` δώστε τουλάχιστον ένα από τα `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5234,6 +5237,7 @@ digits Malayalam Μαλαγιάλαμ
VectorCraft Title Bar on Every Desktop Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου.
System Title Bar takes precedence over this option while it is on. Όσο είναι ενεργή, η Γραμμή τίτλου συστήματος υπερισχύει αυτής της επιλογής.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Όσο η Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας είναι ανενεργή, αυτή η επιφάνεια εργασίας σχεδιάζει τη γραμμή τίτλου του παραθύρου, είτε αυτή η επιλογή είναι ενεργή είτε όχι.
Value Axis Άξονας τιμών
On Left Side Στην αριστερή πλευρά
On Right Side Στη δεξιά πλευρά
@@ -6552,6 +6556,8 @@ axis Y Axis Άξονας Y
Action Options… Επιλογές ενέργειας…
Action Options Επιλογές ενέργειας
Playback Options… Επιλογές αναπαραγωγής…
Button Mode Λειτουργία κουμπιών
No actions. Δεν υπάρχουν ενέργειες.
Insert Stop… Εισαγωγή διακοπής…
Insert Stop Εισαγωγή διακοπής
Record Stop Καταγραφή διακοπής
Can't render this file because it is too large.
+6
View File
@@ -705,6 +705,8 @@ font-technology Variable Variables
Crop Image Recortar imagen
Image Tools Herramientas de imagen
Remove Background… Quitar fondo…
Draw (Pencil) Dibujar (Lápiz)
Place Image Colocar imagen
Remove Background Quitar fondo
Background Fondo
Edge Color Color del borde
@@ -4489,6 +4491,7 @@ axis Both Ambos
@msg `select` must be true or false `select` debe ser true o false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` y `maxAnchors` deben ser números enteros
@msg `{key}` must be a number not below 0 `{key}` debe ser un número no inferior a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` debe ser cuatro números no inferiores a 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` debe ser [x, y, width, height]: cuatro números finitos, con anchura y altura no negativas
@msg `{min_key}` is greater than `{max_key}` `{min_key}` es mayor que `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` indica al menos uno de `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5323,6 +5326,7 @@ axis Both Ambos
VectorCraft Title Bar on Every Desktop Barra de título de VectorCraft en todos los escritorios
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Mientras está definida, la variable de entorno VECTORCRAFT_TITLEBAR elige la barra de título.
System Title Bar takes precedence over this option while it is on. Mientras está activada, Barra de título del sistema tiene prioridad sobre esta opción.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Mientras Barra de título de VectorCraft en todos los escritorios está desactivada, este escritorio dibuja la barra de título de la ventana tanto si esta opción está activada como si no.
Value Axis Eje de valores
On Left Side En el lado izquierdo
On Right Side En el lado derecho
@@ -6543,6 +6547,8 @@ axis Y Axis Eje Y
Action Options… Opciones de acción…
Action Options Opciones de acción
Playback Options… Opciones de reproducción…
Button Mode Modo de botones
No actions. No hay acciones.
Insert Stop… Insertar parada…
Insert Stop Insertar parada
Record Stop Grabar parada
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -214,6 +214,8 @@
Crop Image Rajaa kuva
Image Tools Kuvatyökalut
Remove Background… Poista tausta…
Draw (Pencil) Piirrä (lyijykynä)
Place Image Sijoita kuva
Remove Background Poista tausta
Background Tausta
Edge Color Reunan väri
@@ -985,6 +987,7 @@
VectorCraft Title Bar on Every Desktop VectorCraftin otsikkorivi kaikissa työpöytäympäristöissä
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Ympäristömuuttuja VECTORCRAFT_TITLEBAR valitsee otsikkorivin niin kauan kuin se on asetettu.
System Title Bar takes precedence over this option while it is on. Järjestelmän otsikkorivi ohittaa tämän asetuksen niin kauan kuin se on käytössä.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Niin kauan kuin VectorCraftin otsikkorivi kaikissa työpöytäympäristöissä ei ole käytössä, tämä työpöytäympäristö piirtää ikkunan otsikkorivin, oli tämä asetus käytössä tai ei.
Exit Lopeta
Distort Vääristä
Diffuse Glow… Hajautettu hehku…
@@ -4712,6 +4715,7 @@ imageTrace Outline Ääriviiva
@msg `select` must be true or false `select` on oltava true tai false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` ja `maxAnchors` on oltava kokonaislukuja
@msg `{key}` must be a number not below 0 `{key}` on oltava luku, joka on vähintään 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` on oltava neljä lukua, jotka ovat vähintään 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` on oltava [x, y, width, height]: neljä äärellistä lukua, joista leveys ja korkeus eivät ole negatiivisia
@msg `{min_key}` is greater than `{max_key}` `{min_key}` on suurempi kuin `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` anna vähintään yksi parametreista `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -6630,6 +6634,8 @@ axis Y Axis Y-akseli
Action Options… Toiminnon asetukset…
Action Options Toiminnon asetukset
Playback Options… Toistoasetukset…
Button Mode Painiketila
No actions. Ei toimintoja.
Insert Stop… Lisää pysäytys…
Insert Stop Lisää pysäytys
Record Stop Nauhoita pysäytys
Can't render this file because it contains an unexpected character in line 2 and column 73.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -18
View File
@@ -1140,6 +1140,8 @@ antiAlias Sharp शार्प
Crop Image इमेज क्रॉप करें
Image Tools इमेज टूल
Remove Background… पृष्ठभूमि हटाएँ…
Draw (Pencil) ड्रा (पेंसिल)
Place Image इमेज प्लेस करें
Remove Background पृष्ठभूमि हटाएँ
Background पृष्ठभूमि
Edge Color किनारे का रंग
@@ -3560,6 +3562,8 @@ antiAlias Sharp शार्प
@msg the action set would be larger than 8 MiB एक्शन सेट 8 MiB से बड़ा हो जाएगा
Playback Options प्लेबैक ऑप्शन्स
Playback Options… प्लेबैक ऑप्शन्स…
Button Mode बटन मोड
No actions. कोई कार्रवाई नहीं।
Plug-in Info प्लगइन जानकारी
Plug-in… प्लग-इन…
Plug-ins प्लग-इन्स
@@ -4997,6 +5001,7 @@ antiAlias Sharp शार्प
VectorCraft Title Bar on Every Desktop हर डेस्कटॉप पर VectorCraft टाइटल बार
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. जब तक एनवायरनमेंट वेरिएबल VECTORCRAFT_TITLEBAR सेट है, टाइटल बार वही चुनता है।
System Title Bar takes precedence over this option while it is on. जब तक सिस्टम टाइटल बार चालू है, उसे इस विकल्प पर प्राथमिकता मिलती है।
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. जब तक हर डेस्कटॉप पर VectorCraft टाइटल बार बंद है, यह डेस्कटॉप विंडो का टाइटल बार बनाता है, चाहे यह विकल्प चालू हो या बंद।
Tab Tab
Tab Stops टैब स्टॉप्स
Table तालिका
@@ -5926,7 +5931,7 @@ antiAlias Sharp शार्प
@msg `{key}` is longer than {MAX_NAME} characters `{key}` {MAX_NAME} अक्षरों से अधिक लंबा है
@msg `{key}` is out of range `{key}` रेंज से बाहर है
@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` को प्रतिशत में [min, desired, max] या {{min?, desired?, max?}} होना चाहिए
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` होना चाहिए [x, y, चौड़ाई, ऊंचाई]: चार परिमित संख्या, चौड़ाई और ऊंचाई नकारात्मक नहीं है
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` होना चाहिए [x, y, width, height]: चार परिमित संख्या, चौड़ाई और ऊंचाई नकारात्मक नहीं है
@msg `{key}` must be [x, y] `{key}` होना चाहिए [x, y]
@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` को `auto` या `en` या `zh-hant` जैसे भाषा कोड होना चाहिए।
@msg `{key}` must be 0..{max} `{key}` होना चाहिए 0.{max}
@@ -5939,6 +5944,7 @@ antiAlias Sharp शार्प
@msg `{key}` must be a number `{key}` एक संख्या होना चाहिए
@msg `{key}` must be a number {_1}..{_2} `{key}` {_1}..{_2} की एक संख्या होना चाहिए
@msg `{key}` must be a number not below 0 `{key}` 0 से कम नहीं होने वाली संख्या होना चाहिए
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` 0 से कम नहीं होने वाली चार संख्याएँ होनी चाहिए: [tl, tr, br, bl]
@msg `{key}` must be a number, not {v} `{key}` एक संख्या होना चाहिए, {v} नहीं
@msg `{key}` must be a profile name or null `{key}` को प्रोफ़ाइल नाम या null होना चाहिए
@msg `{key}` must be a string `{key}` एक स्ट्रिंग होना चाहिए
@@ -6011,7 +6017,7 @@ antiAlias Sharp शार्प
@msg `a` and `b` must be two different paths `a` और `b` को दो अलग-अलग पाथ होना चाहिए
@msg `align` is start, center or end `align` शुरू, केंद्र या अंत है
@msg `align` must be "left", "center" or "right", not {other} `align` होना चाहिए "left", "center" या "right", नहीं {other}
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, एंकर] जोड़े की एक सारणी होना चाहिए
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, anchor] जोड़े की एक सारणी होना चाहिए
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {k} नहीं
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {v} नहीं
@msg `antiAlias` must be "none", "sharp", "crisp" or "strong" `antiAlias` "none", "sharp", "crisp" या "strong" होना चाहिए
@@ -6397,7 +6403,7 @@ antiAlias Sharp शार्प
@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise अपने फ़ॉन्ट की एक लाइन के लिए बहुत छोटे फ़्रेम वाले एरिया टाइप को लंबे फ़्रेम दिए गए ({_1}): वरना उनका टाइप नहीं दिखता
@msg area type needs a path that encloses an area एरिया टाइप को ऐसा पाथ चाहिए जो किसी क्षेत्र को घेरता हो
@msg arrowAlign must be extend|tip, got {o} arrowAlign को extend|tip होना चाहिए, {o} मिला
@msg arrowScale must be [start, end] arrowScale होना चाहिए [शुरू, अंत]
@msg arrowScale must be [start, end] arrowScale होना चाहिए [start, end]
@msg artboard {0} does not exist आर्टबोर्ड {0} मौजूद नहीं है
@msg artboard {i} doesn't print आर्टबोर्ड {i} प्रिंट नहीं करता है
@msg artboard is a 0-based index artboard 0 से शुरू होने वाला इंडेक्स है
@@ -6458,14 +6464,14 @@ antiAlias Sharp शार्प
@msg bad edit बुरा संपादन
@msg bad fill {v} बुरा भर {v}
@msg bad fit: {e} बुरा फिट: {e}
@msg bad model {other} (keep, new or document) बुरा मॉडल {other} (keep, नया या डॉक्यूमेंट)
@msg bad model {other} (keep, new or document) बुरा मॉडल {other} (keep, new या document)
@msg bad new colour {v} बुरा नया रंग {v}
@msg bad object बुरा ऑब्जेक्ट
@msg bad object number खराब ऑब्जेक्ट संख्या
@msg bad offset बुरा ऑफसेट
@msg bad page tree बुरा पेज पेड़
@msg bad path data: {e} बुरा पाथ डेटा: {e}
@msg bad point {v}: expected [x, y] or [x, y, pressure] बुरा बिंदु {v}: उम्मीद [x, y] या [x, y, दबाव]
@msg bad point {v}: expected [x, y] or [x, y, pressure] बुरा बिंदु {v}: उम्मीद [x, y] या [x, y, pressure]
@msg bad reference बुरा संदर्भ
@msg bad shortcut बुरा शॉर्टकट
@msg bad stream length खराब धारा की लंबाई
@@ -6995,7 +7001,7 @@ antiAlias Sharp शार्प
@msg Internal error: {msg} (please report this bug) आंतरिक त्रुटि: {msg} (कृपया इस बग की रिपोर्ट करें)
@msg invalid `color` अमान्य `color`
@msg invalid anchor index {v}: expected a non-negative integer अमान्य एंकर इंडेक्स {v}: एक गैर-नकारात्मक पूर्णांक की उम्मीद
@msg invalid anchor reference {v}: expected [subpath, anchor] अमान्य एंकर संदर्भ {v}: उम्मीद [subpath, एंकर]
@msg invalid anchor reference {v}: expected [subpath, anchor] अमान्य एंकर संदर्भ {v}: उम्मीद [subpath, anchor]
@msg invalid angle अमान्य कोण
@msg invalid brush: {e} अमान्य ब्रश: {e}
@msg invalid color अमान्य रंग
@@ -7172,7 +7178,7 @@ antiAlias Sharp शार्प
@msg missing `value` लापता `value`
@msg missing anchor लापता एंकर
@msg missing anchors लापता एंकर
@msg missing anchors [[subpath, anchor]…] लापता एंकर [[subpath, एंकर]...]
@msg missing anchors [[subpath, anchor]…] लापता एंकर [[subpath, anchor]...]
@msg missing array `{key}` लापता सरणी `{key}`
@msg missing case लापता मामला
@msg missing color लापता रंग
@@ -8384,7 +8390,7 @@ antiAlias Sharp शार्प
@msg Unembedded to {_1} एम्बेड हटाकर {_1} में रखा गया
@msg unexpected host trap होस्ट में अप्रत्याशित ट्रैप
@msg unexpected resources अप्रत्याशित संसाधन
@msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (आकार, स्थिति)
@msg unknown {k} key `{key}` (size, position) अज्ञात {k} कुंजी `{key}` (size, position)
@msg unknown {key} `{v}` (none, short or full) अज्ञात {key} `{v}` (none, short या full)
@msg unknown {key} profile `{n}` अज्ञात {key} प्रोफ़ाइल `{n}`
@msg unknown align `{other}` अज्ञात संरेखण `{other}`
@@ -8417,9 +8423,9 @@ antiAlias Sharp शार्प
@msg unknown format अज्ञात स्वरूप
@msg unknown format `{f}` (Save writes {_1}) अज्ञात फ़ॉर्मैट `{f}` (सेव {_1} लिखता है)
@msg unknown format `{f}` (vcswatches, gpl, ase or css) अज्ञात फ़ॉर्मैट `{f}` (vcswatches, gpl, ase या css)
@msg unknown freeform mode `{m}` (points, lines) अज्ञात फ्रीफॉर्म मोड `{m}` (बिंदुओं, लाइनों)
@msg unknown freeform mode `{m}` (points, lines) अज्ञात फ्रीफॉर्म मोड `{m}` (points, lines)
@msg unknown from {f} {f} से अज्ञात
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) अज्ञात ग्रेडिएंट `{k}` (रैखिक, रेडियल, शंक्वाकार, डायमंड, फ्रीफॉर्म)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) अज्ञात ग्रेडिएंट `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown graph type `{t}` अज्ञात ग्राफ प्रकार `{t}`
@msg unknown grid colours `{name}` (one of {_1}) अज्ञात ग्रिड रंग `{name}` ({_1} में से एक)
@msg unknown gridType `{t}` अज्ञात gridType `{t}`
@@ -8434,20 +8440,20 @@ antiAlias Sharp शार्प
@msg unknown method `{m}` ({_1}) अज्ञात विधि `{m}` ({_1})
@msg unknown mode {m} अज्ञात मोड {m}
@msg unknown mode `{o}` अज्ञात मोड `{o}`
@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) अज्ञात मोड `{other}` (ग्रे, RGB, hsb, लैब, cmyk या वेब)
@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) अज्ञात मोड `{other}` (gray, rgb, hsb, lab, cmyk या web)
@msg unknown mode `{other}` (mono|cmyk) अज्ञात मोड `{other}` (mono|cmyk)
@msg unknown mode `{other}` (rgb|cmyk|gray|global) अज्ञात मोड `{other}` (rgb|cmyk|gray|global)
@msg unknown mode `{other}` (set, add, subtract) अज्ञात मोड `{other}` (सेट, ऐड, घटाना)
@msg unknown mode `{other}` (set, add, subtract) अज्ञात मोड `{other}` (set, add, subtract)
@msg unknown model `{other}` अज्ञात मॉडल `{other}`
@msg unknown model `{other}` (keep, new or document) अज्ञात मॉडल `{other}` (keep, नया या डॉक्यूमेंट)
@msg unknown model `{other}` (keep, new or document) अज्ञात मॉडल `{other}` (keep, new या document)
@msg unknown op {o} अज्ञात op {o}
@msg unknown OpenType feature `{t}` अज्ञात ओपन टाइप सुविधा `{t}`
@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; prefix - to turn off) अज्ञात OpenType फ़ीचर `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom; बंद करने के लिए आगे - लगाएँ)
@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) featureChanges में अज्ञात OpenType फ़ीचर `{t}` (वे टैग जो `features` लेता है)
@msg unknown option `{path}.{k}` अज्ञात ऑप्शन `{path}.{k}`
@msg unknown order `{o}`: toggle, front, forward, backward or back अज्ञात आदेश `{o}`: टॉगल, फ्रंट, फॉरवर्ड, बैकवर्ड या बैक
@msg unknown overprints `{o}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{o}` (preserve, अनुकरण या discard)
@msg unknown overprints `{v}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{v}` (प्ररक्षित, अनुकरण या त्यागपत्र)
@msg unknown overprints `{o}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{o}` (preserve, simulate या discard)
@msg unknown overprints `{v}` (preserve, simulate or discard) अज्ञात ओवरप्रिंट `{v}` (preserve, simulate या discard)
@msg unknown panel अज्ञात पैनल
@msg unknown panel `{raw}` अज्ञात पैनल `{raw}`
@msg panels can't be shown in Presentation Mode प्रस्तुति मोड में पैनल्स नहीं दिखाए जा सकते
@@ -8462,7 +8468,7 @@ antiAlias Sharp शार्प
@msg `{path}` is nested too deeply `{path}` बहुत गहराई तक नेस्ट किया गया है
@msg unknown PDF preset `{name}` (presets: {_1}) अज्ञात PDF प्रीसेट `{name}` (presets: {_1})
@msg unknown pieLegend `{v}` (none, standard or wedges) अज्ञात pieLegend `{v}` (none, standard या wedges)
@msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (यहां तक कि अनुपात या स्टैक्ड)
@msg unknown piePosition `{v}` (even, ratio or stacked) अज्ञात piePosition `{v}` (even, ratio या stacked)
@msg unknown pieSort `{v}` (none, all or first) अज्ञात pieSort `{v}` (none, all या first)
@msg unknown plate `{n}` अज्ञात प्लेट `{n}`
@msg unknown preference `{k}` अज्ञात वरीयता `{k}`
@@ -8488,7 +8494,7 @@ antiAlias Sharp शार्प
@msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) अज्ञात प्रीसेट `{name}`: एक अंतर्निहित या सेव किए गए प्रीसेट नहीं (imageTrace.presets देखें)
@msg unknown profile {id} (see stroke.widthProfile.list) अज्ञात प्रोफ़ाइल {id} (stroke.widthProfile.list देखें)
@msg unknown proof target `{t}` अज्ञात प्रमाण लक्ष्य `{t}`
@msg unknown quotes key `{k}` (double, single) अज्ञात उद्धरण कुंजी `{k}` (डबल, सिंगल)
@msg unknown quotes key `{k}` (double, single) अज्ञात उद्धरण कुंजी `{k}` (double, single)
@msg unknown resolution `{v}` अज्ञात रिज़ॉल्यूशन `{v}`
@msg unknown rule `{r}` (see color.harmony) अज्ञात नियम `{r}` (देखें color.harmony)
@msg unknown rule `{rule}` ({_1}) अज्ञात नियम `{rule}` ({_1})
@@ -8504,7 +8510,7 @@ antiAlias Sharp शार्प
@msg Unknown unit अज्ञात इकाई
@msg unknown units अज्ञात इकाइयों
@msg unknown units `{u}` अज्ञात इकाइयों `{u}`
@msg unknown value axis `{v}` (left, right or both) अज्ञात मान अक्ष `{v}` (बाएं, दाएं या दोनों)
@msg unknown value axis `{v}` (left, right or both) अज्ञात मान अक्ष `{v}` (left, right या both)
@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) अज्ञात variation `{v}` (tintsShades|warmCool|vividMuted)
@msg unknown Warp style अज्ञात ताना स्टाइल
@msg unknown warp style `{s}` अज्ञात ताना स्टाइल `{s}`
Can't render this file because it is too large.
+6
View File
@@ -906,6 +906,8 @@ font-technology Variable Változó
Crop Image Kép körülvágása
Image Tools Képeszközök
Remove Background… Háttér eltávolítása…
Draw (Pencil) Rajzolás (Ceruza)
Place Image Kép elhelyezése
Remove Background Háttér eltávolítása
Background Háttér
Edge Color Szélszín
@@ -4311,6 +4313,7 @@ mark-style Japanese Japán
VectorCraft Title Bar on Every Desktop A VectorCraft címsora minden asztali környezetben
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Amíg a VECTORCRAFT_TITLEBAR környezeti változó be van állítva, az választja ki a címsort.
System Title Bar takes precedence over this option while it is on. Amíg a Rendszer címsora be van kapcsolva, elsőbbséget élvez ezzel a beállítással szemben.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Amíg a VectorCraft címsora minden asztali környezetben beállítás ki van kapcsolva, ez az asztali környezet rajzolja az ablak címsorát, akár be van kapcsolva ez a beállítás, akár nem.
Tab Tabulátor
Tab Stops Tabulátorpontok
Table Táblázat
@@ -4913,6 +4916,7 @@ imageTrace Outline Körvonal
@msg `select` must be true or false `select`: true vagy false kell
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` és `maxAnchors`: egész szám kell
@msg `{key}` must be a number not below 0 `{key}`: legalább 0 értékű szám kell
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}`: négy legalább 0 értékű szám kell: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}`: [x, y, width, height] kell, négy véges szám, a szélesség és a magasság nem negatív
@msg `{min_key}` is greater than `{max_key}` `{min_key}` nagyobb, mint `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` adj meg legalább egyet ezek közül: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -6583,6 +6587,8 @@ axis Y Axis Y tengely
Action Options… Művelet beállításai…
Action Options Művelet beállításai
Playback Options… Lejátszási beállítások…
Button Mode Gomb mód
No actions. Nincs művelet.
Insert Stop… Megállás beszúrása…
Insert Stop Megállás beszúrása
Record Stop Megállás rögzítése
Can't render this file because it contains an unexpected character in line 2 and column 73.
+10 -5
View File
@@ -194,6 +194,8 @@
Crop Image Pangkas gambar
Image Tools Alat Gambar
Remove Background… Hapus latar belakang…
Draw (Pencil) Menggambar (pensil)
Place Image Tempatkan gambar
Remove Background Hapus latar belakang
Background Latar belakang
Edge Color Warna tepi
@@ -924,6 +926,7 @@
VectorCraft Title Bar on Every Desktop Bilah judul VectorCraft di setiap desktop
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Selama ditetapkan, variabel lingkungan VECTORCRAFT_TITLEBAR memilih bilah judul.
System Title Bar takes precedence over this option while it is on. Selama aktif, Bilah judul sistem lebih diutamakan daripada opsi ini.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Selama Bilah judul VectorCraft di setiap desktop nonaktif, desktop ini menggambar bilah judul jendela, baik opsi ini aktif maupun tidak.
Exit Keluar
Distort Distorsi
Diffuse Glow… Pendar difusi…
@@ -4795,11 +4798,11 @@ imageTrace Outline Kontur
@msg unknown effect `{id}` efek `{id}` tidak dikenal
@msg unknown format format tidak dikenal
@msg unknown format `{f}` (Save writes {_1}) format `{f}` tidak dikenal (Simpan menulis {_1})
@msg unknown freeform mode `{m}` (points, lines) mode bentuk bebas `{m}` tidak dikenal (titik, garis)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, kerucut, wajik, bentuk bebas)
@msg unknown freeform mode `{m}` (points, lines) mode bentuk bebas `{m}` tidak dikenal (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) jenis gradien `{k}` tidak dikenal (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` sorotan `{h}` tidak dikenal
@msg unknown option `{path}.{k}` opsi `{path}.{k}` tidak dikenal
@msg unknown overprints `{o}` (preserve, simulate or discard) overprint `{o}` tidak dikenal (pertahankan, simulasikan, atau buang)
@msg unknown overprints `{o}` (preserve, simulate or discard) overprint `{o}` tidak dikenal (preserve, simulate, atau discard)
@msg unknown panel `{raw}` panel `{raw}` tidak dikenal
@msg unknown preference `{key}` preferensi `{key}` tidak dikenal
@msg Preferences imported. Preferensi diimpor.
@@ -4817,10 +4820,10 @@ imageTrace Outline Kontur
@msg `{key}` must be an object `{key}` harus berupa objek
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` dijalankan sendiri, bukan sebagai langkah batch
@msg unknown preset (one of {_1}) preset tidak dikenal (salah satu dari {_1})
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preset `{name}` tidak dikenal (tinggi, sedang, rendah, atau yang tersimpan: lihat flattener.presets.list)
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) preset `{name}` tidak dikenal (high, medium, low, atau yang tersimpan: lihat flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) preset `{name}` tidak dikenal (lihat flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) preset `{name}` tidak dikenal (lihat webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) sisi `{other}` tidak dikenal (kiri|kanan|atas|bawah)
@msg unknown side `{other}` (left|right|top|bottom) sisi `{other}` tidak dikenal (left|right|top|bottom)
@msg unknown tool `{tool}` alat `{tool}` tidak dikenal
@msg unknown tool `{t}` alat `{t}` tidak dikenal
@msg unknown tool or command `{key}` alat atau perintah `{key}` tidak dikenal
@@ -6073,6 +6076,8 @@ axis Y Axis Sumbu Y
Action Options… Opsi tindakan…
Action Options Opsi tindakan
Playback Options… Opsi pemutaran…
Button Mode Mode tombol
No actions. Tidak ada aksi.
Insert Stop… Sisipkan henti…
Insert Stop Sisipkan henti
Record Stop Rekam henti
Can't render this file because it contains an unexpected character in line 2432 and column 5.
+6
View File
@@ -705,6 +705,8 @@ font-technology Variable Variabili
Crop Image Ritaglia immagine
Image Tools Strumenti immagine
Remove Background… Rimuovi sfondo…
Draw (Pencil) Disegna (Matita)
Place Image Inserisci immagine
Remove Background Rimuovi sfondo
Background Sfondo
Edge Color Colore del bordo
@@ -4541,6 +4543,7 @@ axis Both Entrambi
@msg `select` must be true or false `select` deve essere true o false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` e `maxAnchors` devono essere numeri interi
@msg `{key}` must be a number not below 0 `{key}` deve essere un numero non inferiore a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` deve essere quattro numeri non inferiori a 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` deve essere [x, y, width, height]: quattro numeri finiti, con larghezza e altezza non negative
@msg `{min_key}` is greater than `{max_key}` `{min_key}` è maggiore di `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` indica almeno uno tra `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5377,6 +5380,7 @@ effect Regular Normale
VectorCraft Title Bar on Every Desktop Barra del titolo di VectorCraft su ogni desktop
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Finché è impostata, la variabile d'ambiente VECTORCRAFT_TITLEBAR sceglie la barra del titolo.
System Title Bar takes precedence over this option while it is on. Finché è attiva, Barra del titolo di sistema ha la precedenza su questa opzione.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Finché Barra del titolo di VectorCraft su ogni desktop è disattivata, questo desktop disegna la barra del titolo della finestra, sia con questa opzione attiva sia disattivata.
Value Axis Asse dei valori
On Left Side Sul lato sinistro
On Right Side Sul lato destro
@@ -6226,6 +6230,8 @@ axis Y Axis Asse Y
Action Options… Opzioni azione…
Action Options Opzioni azione
Playback Options… Opzioni di riproduzione…
Button Mode Modalità pulsante
No actions. Nessuna azione.
Insert Stop… Inserisci interruzione…
Insert Stop Inserisci interruzione
Record Stop Registra interruzione
Can't render this file because it is too large.
+6
View File
@@ -242,6 +242,8 @@ digits Malayalam マラヤーラム数字
Crop Image 画像の切り抜き
Image Tools 画像ツール
Remove Background… 背景を削除…
Draw (Pencil) 描画 (鉛筆)
Place Image 画像を配置
Remove Background 背景を削除
Background 背景
Edge Color 端の色
@@ -5011,6 +5013,7 @@ swatch Bright Violet ビビッドバイオレット
@msg `select` must be true or false `select` には true または false を指定してください
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` と `maxAnchors` は整数にしてください
@msg `{key}` must be a number not below 0 `{key}` には 0 以上の数値を指定してください
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` には 0 以上の数値を 4 つ指定してください: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` は [x, y, width, height] の形式で指定してください: 有限の数値 4 つで、幅と高さは負の値にできません
@msg `{min_key}` is greater than `{max_key}` `{min_key}` が `{max_key}` より大きくなっています
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` 次のうち少なくとも 1 つを指定してください: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5419,6 +5422,7 @@ effect Vertical 垂直
VectorCraft Title Bar on Every Desktop すべてのデスクトップで VectorCraft のタイトルバー
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 環境変数 VECTORCRAFT_TITLEBAR が設定されている間は、この変数でタイトルバーが決まります。
System Title Bar takes precedence over this option while it is on. システムのタイトルバーがオンの間は、そちらがこのオプションより優先されます。
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. すべてのデスクトップで VectorCraft のタイトルバーがオフの間は、このオプションのオンとオフにかかわらず、このデスクトップがウィンドウのタイトルバーを描画します。
Value Axis 数値軸
On Left Side 左側
On Right Side 右側
@@ -6292,6 +6296,8 @@ axis Y Axis Y 軸
Action Options… アクションオプション…
Action Options アクションオプション
Playback Options… 再生オプション…
Button Mode ボタンモード
No actions. アクションがありません。
Insert Stop… 停止を挿入…
Insert Stop 停止を挿入
Record Stop 停止を記録
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -166,6 +166,8 @@
Crop Image გამოსახულების ჩამოჭრა
Image Tools გამოსახულების ხელსაწყოები
Remove Background… ფონის მოშორება…
Draw (Pencil) ხატვა (ფანქარი)
Place Image გამოსახულების განთავსება
Remove Background ფონის მოშორება
Background ფონი
Edge Color კიდის ფერი
@@ -890,6 +892,7 @@
VectorCraft Title Bar on Every Desktop VectorCraft-ის სათაურის ზოლი ყველა სამუშაო მაგიდაზე
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. სანამ გარემოს ცვლადი VECTORCRAFT_TITLEBAR დაყენებულია, სათაურის ზოლს ის ირჩევს.
System Title Bar takes precedence over this option while it is on. სანამ სისტემის სათაურის ზოლი ჩართულია, მას ამ პარამეტრზე უპირატესობა აქვს.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. სანამ VectorCraft-ის სათაურის ზოლი ყველა სამუშაო მაგიდაზე გამორთულია, ეს სამუშაო მაგიდა ფანჯრის სათაურის ზოლს ხატავს, ეს პარამეტრი ჩართულია თუ გამორთული.
Exit გასვლა
Distort დეფორმაცია
Diffuse Glow… გაბნეული ნათება…
@@ -4185,6 +4188,7 @@ font-technology Variable ცვლადი
@msg `select` must be true or false `select` უნდა იყოს true ან false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` და `maxAnchors` უნდა იყოს მთელი რიცხვები
@msg `{key}` must be a number not below 0 `{key}` უნდა იყოს რიცხვი, არანაკლებ 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` უნდა იყოს ოთხი რიცხვი, თითოეული არანაკლებ 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` უნდა იყოს [x, y, width, height]: ოთხი სასრული რიცხვი, სიგანე და სიმაღლე არ უნდა იყოს უარყოფითი
@msg `{min_key}` is greater than `{max_key}` `{min_key}` მეტია, ვიდრე `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` მიუთითეთ ერთი მაინც: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -6551,6 +6555,8 @@ axis Y Axis Y ღერძი
Action Options… მოქმედების პარამეტრები…
Action Options მოქმედების პარამეტრები
Playback Options… შესრულების პარამეტრები…
Button Mode ღილაკების რეჟიმი
No actions. მოქმედებები არ არის.
Insert Stop… გაჩერების ჩასმა…
Insert Stop გაჩერების ჩასმა
Record Stop გაჩერების ჩაწერა
Can't render this file because it is too large.
File diff suppressed because it is too large Load Diff
+11 -5
View File
@@ -157,6 +157,7 @@
@msg `select` must be true or false `select` moet true of false zijn
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` en `maxAnchors` moeten gehele getallen zijn
@msg `{key}` must be a number not below 0 `{key}` moet een getal van minstens 0 zijn
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` moet uit vier getallen van minstens 0 bestaan: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` moet [x, y, width, height] zijn: vier eindige getallen, width en height niet negatief
@msg `{min_key}` is greater than `{max_key}` `{min_key}` is groter dan `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` geef minstens een van `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -711,6 +712,8 @@
Crop Image Afbeelding bijsnijden
Image Tools Afbeeldingshulpmiddelen
Remove Background… Achtergrond verwijderen…
Draw (Pencil) Tekenen (Potlood)
Place Image Afbeelding plaatsen
Remove Background Achtergrond verwijderen
Background Achtergrond
Edge Color Randkleur
@@ -1441,6 +1444,7 @@
VectorCraft Title Bar on Every Desktop VectorCraft-titelbalk op elk bureaublad
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Zolang de omgevingsvariabele VECTORCRAFT_TITLEBAR is ingesteld, kiest die de titelbalk.
System Title Bar takes precedence over this option while it is on. Zolang Systeemtitelbalk is ingeschakeld, heeft die voorrang op deze optie.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Zolang VectorCraft-titelbalk op elk bureaublad is uitgeschakeld, tekent dit bureaublad de titelbalk van het venster, of deze optie nu aan of uit staat.
Exit Afsluiten
Distort Vervormen
Diffuse Glow… Diffuse gloed…
@@ -5312,11 +5316,11 @@ imageTrace Outline Contour
@msg unknown effect `{id}` onbekend effect `{id}`
@msg unknown format onbekende indeling
@msg unknown format `{f}` (Save writes {_1}) onbekende indeling `{f}` (Opslaan schrijft {_1})
@msg unknown freeform mode `{m}` (points, lines) onbekende vrijvormmodus `{m}` (punten, lijnen)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) onbekend verlooptype `{k}` (lineair, radiaal, conisch, ruit, vrijvorm)
@msg unknown freeform mode `{m}` (points, lines) onbekende vrijvormmodus `{m}` (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) onbekend verlooptype `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` onbekende markering `{h}`
@msg unknown option `{path}.{k}` onbekende optie `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) onbekende overdruk `{o}` (behouden, simuleren of verwerpen)
@msg unknown overprints `{o}` (preserve, simulate or discard) onbekende overdruk `{o}` (preserve, simulate of discard)
@msg unknown panel `{raw}` onbekend paneel `{raw}`
@msg unknown preference `{key}` onbekende voorkeur `{key}`
@msg Preferences imported. Voorkeuren geïmporteerd.
@@ -5334,10 +5338,10 @@ imageTrace Outline Contour
@msg `{key}` must be an object `{key}` moet een object zijn
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` wordt los uitgevoerd, niet als stap van een batch
@msg unknown preset (one of {_1}) onbekende voorinstelling (één van {_1})
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) onbekende voorinstelling `{name}` (hoog, middel, laag of een opgeslagen voorinstelling: zie flattener.presets.list)
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) onbekende voorinstelling `{name}` (high, medium, low of een opgeslagen voorinstelling: zie flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) onbekende voorinstelling `{name}` (zie flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) onbekende voorinstelling `{name}` (zie webExport.presets.list)
@msg unknown side `{other}` (left|right|top|bottom) onbekende zijde `{other}` (links|rechts|boven|onder)
@msg unknown side `{other}` (left|right|top|bottom) onbekende zijde `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` onbekend gereedschap `{tool}`
@msg unknown tool `{t}` onbekend gereedschap `{t}`
@msg unknown tool or command `{key}` onbekend gereedschap of opdracht `{key}`
@@ -6695,6 +6699,8 @@ axis Y Axis Y-as
Action Options… Actieopties…
Action Options Actieopties
Playback Options… Afspeelopties…
Button Mode Knopmodus
No actions. Geen acties.
Insert Stop… Stop invoegen…
Insert Stop Stop invoegen
Record Stop Stop opnemen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+6
View File
@@ -171,6 +171,8 @@
Crop Image Kadruj obraz
Image Tools Narzędzia obrazu
Remove Background… Usuń tło…
Draw (Pencil) Rysuj (ołówek)
Place Image Umieść obraz
Remove Background Usuń tło
Background Tło
Edge Color Kolor krawędzi
@@ -942,6 +944,7 @@
VectorCraft Title Bar on Every Desktop Pasek tytułu VectorCraft na każdym pulpicie
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Dopóki zmienna środowiskowa VECTORCRAFT_TITLEBAR jest ustawiona, to ona wybiera pasek tytułu.
System Title Bar takes precedence over this option while it is on. Dopóki Systemowy pasek tytułu jest włączony, ma pierwszeństwo przed tą opcją.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Dopóki Pasek tytułu VectorCraft na każdym pulpicie jest wyłączony, ten pulpit rysuje pasek tytułu okna niezależnie od tego, czy ta opcja jest włączona.
Exit Zakończ
Distort Zniekształcenie
Diffuse Glow… Rozproszony blask…
@@ -4622,6 +4625,7 @@ imageTrace Outline Kontur
@msg `select` must be true or false `select` musi mieć wartość true lub false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` i `maxAnchors` muszą być liczbami całkowitymi
@msg `{key}` must be a number not below 0 `{key}` musi być liczbą nie mniejszą niż 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` musi być czterema liczbami nie mniejszymi niż 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` musi mieć postać [x, y, width, height]: cztery skończone liczby, szerokość i wysokość nieujemne
@msg `{min_key}` is greater than `{max_key}` `{min_key}` jest większe niż `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` podaj co najmniej jeden z parametrów `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -6455,6 +6459,8 @@ axis Y Axis Oś Y
Action Options… Opcje operacji…
Action Options Opcje operacji
Playback Options… Opcje odtwarzania…
Button Mode Tryb przycisków
No actions. Brak operacji.
Insert Stop… Wstaw zatrzymanie…
Insert Stop Wstaw zatrzymanie
Record Stop Nagraj zatrzymanie
Can't render this file because it is too large.
+10 -4
View File
@@ -631,6 +631,8 @@ antiAlias Sharp Nítido
Crop Image Recortar Imagem
Image Tools Ferramentas de imagem
Remove Background… Remover Fundo…
Draw (Pencil) Desenhar (Lápis)
Place Image Colocar Imagem
Remove Background Remover Fundo
Background Fundo
Edge Color Cor da Borda
@@ -3490,6 +3492,7 @@ imageTrace Outline Contorno
VectorCraft Title Bar on Every Desktop Barra de título do VectorCraft em todas as áreas de trabalho
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Enquanto estiver definida, a variável de ambiente VECTORCRAFT_TITLEBAR escolhe a barra de título.
System Title Bar takes precedence over this option while it is on. Enquanto estiver ativada, Barra de título do sistema tem prioridade sobre esta opção.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Enquanto Barra de título do VectorCraft em todas as áreas de trabalho estiver desativada, esta área de trabalho desenha a barra de título da janela com esta opção ativada ou desativada.
Exit Sair
Distort Distorcer
Diffuse Glow… Brilho difuso…
@@ -3952,6 +3955,7 @@ axis Both Ambos
@msg `select` must be true or false `select` deve ser true ou false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` e `maxAnchors` devem ser números inteiros
@msg `{key}` must be a number not below 0 `{key}` deve ser um número não inferior a 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` deve ser quatro números não inferiores a 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` deve ser [x, y, width, height]: quatro números finitos, com largura e altura não negativas
@msg `{min_key}` is greater than `{max_key}` `{min_key}` é maior que `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` informe pelo menos um de `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -6515,6 +6519,8 @@ axis Y Axis Eixo Y
Action Options… Opções da Ação…
Action Options Opções da Ação
Playback Options… Opções de Reprodução…
Button Mode Modo de Botão
No actions. Nenhuma ação.
Insert Stop… Inserir Parada…
Insert Stop Inserir Parada
Record Stop Gravar Parada
@@ -7644,7 +7650,7 @@ swatch Yellow Green Verde-amarelado
@msg `alignOn` must be a string `alignOn` deve ser uma string
@msg `align` is start, center or end `align` é start, center ou end
@msg `align` must be a string `align` deve ser uma string
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` deve ser uma matriz de pares [subcaminho, ponto de ancoragem]
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` deve ser uma matriz de pares [subpath, anchor]
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` deve listar índices de pontos de ancoragem (0, 1, 2…), não {k}
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` deve listar índices de pontos de ancoragem (0, 1, 2…), não {v}
@msg `answer` for this {kind} dialog must be one of: {_1} `answer` para esta caixa de diálogo {kind} deve ser um de: {_1}
@@ -7709,7 +7715,7 @@ swatch Yellow Green Verde-amarelado
@msg `fidelity` must be from {lo} to {hi} pt `fidelity` deve estar entre {lo} e {hi} pt
@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` deve ter de 1 a 1000 caminhos de arquivos de fonte encontrados pela última pesquisa de text.findFontFiles
@msg `focal` and `radius` cannot be used together `focal` e `radius` não podem ser usados juntos
@msg `focal` must be an array of [subpath, anchor] pairs `focal` deve ser uma matriz de pares [subcaminho, ponto de ancoragem]
@msg `focal` must be an array of [subpath, anchor] pairs `focal` deve ser uma matriz de pares [subpath, anchor]
@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` indica [{si}, {ai}], que não é um ponto de ancoragem do caminho
@msg `folder` must be an absolute folder path `folder` deve ser um caminho absoluto de pasta
@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` deve ser um objeto de tags de eixo e valores, como {"wght"}
@@ -8374,7 +8380,7 @@ swatch Yellow Green Verde-amarelado
@msg instruction {pc} reached with stack depths {seen} and {d} instrução {pc} alcançada com profundidades de pilha {seen} e {d}
@msg invalid `color` `color` inválido
@msg invalid anchor index {v}: expected a non-negative integer índice de ponto de ancoragem inválido {v}: esperado um inteiro não negativo
@msg invalid anchor reference {v}: expected [subpath, anchor] referência de ponto de ancoragem inválida {v}: esperado [subcaminho, ponto de ancoragem]
@msg invalid anchor reference {v}: expected [subpath, anchor] referência de ponto de ancoragem inválida {v}: esperado [subpath, anchor]
@msg invalid angle ângulo inválido
@msg invalid array length comprimento de matriz inválido
@msg invalid brush: {e} pincel inválido: {e}
@@ -8483,7 +8489,7 @@ swatch Yellow Green Verde-amarelado
@msg missing `{k}` falta `{k}`
@msg missing anchor falta o ponto de ancoragem
@msg missing anchors faltam os pontos de ancoragem
@msg missing anchors [[subpath, anchor]…] faltam os pontos de ancoragem [[subcaminho, ponto de ancoragem]…]
@msg missing anchors [[subpath, anchor]…] faltam os pontos de ancoragem [[subpath, anchor]…]
@msg missing array `{key}` falta a matriz `{key}`
@msg missing case falta case
@msg missing color falta a cor
Can't render this file because it is too large.
+122
View File
@@ -703,6 +703,8 @@ font-technology Variable Вариативные
Crop Image Обрезать изображение
Image Tools Инструменты изображения
Remove Background… Удалить фон…
Draw (Pencil) Рисовать (Карандаш)
Place Image Поместить изображение
Remove Background Удалить фон
Background Фон
Edge Color Цвет края
@@ -4526,6 +4528,7 @@ axis Both Оба
@msg `select` must be true or false `select` должно быть true или false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` и `maxAnchors` должны быть целыми числами
@msg `{key}` must be a number not below 0 `{key}` должно быть числом не меньше 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` должно быть четырьмя числами не меньше 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` должно быть [x, y, width, height]: четыре конечных числа, ширина и высота не отрицательные
@msg `{min_key}` is greater than `{max_key}` `{min_key}` больше, чем `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` укажите хотя бы один из параметров `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5362,6 +5365,7 @@ axis Both Оба
VectorCraft Title Bar on Every Desktop Строка заголовка VectorCraft на любом рабочем столе
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Пока переменная среды VECTORCRAFT_TITLEBAR задана, строку заголовка выбирает она.
System Title Bar takes precedence over this option while it is on. Пока включена «Системная строка заголовка», она имеет приоритет над этим параметром.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Пока «Строка заголовка VectorCraft на любом рабочем столе» выключена, этот рабочий стол рисует строку заголовка окна независимо от того, включён ли этот параметр.
Value Axis Ось значений
On Left Side Слева
On Right Side Справа
@@ -6211,6 +6215,8 @@ axis Y Axis Ось Y
Action Options… Параметры действия…
Action Options Параметры действия
Playback Options… Параметры воспроизведения…
Button Mode Режим кнопок
No actions. Нет действий.
Insert Stop… Вставить остановку…
Insert Stop Вставить остановку
Record Stop Записать остановку
@@ -7584,3 +7590,119 @@ shape Open Открытый
Grommets Люверсы
Grommet Inset Отступ люверсов от края
Diameter Диаметр
# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import.
@msg 3D effects require finite coordinates within 100 million points 3D-эффектам нужны конечные координаты в пределах 100 миллионов пунктов
@msg 3D effects require one to ten lights 3D-эффектам нужно от одного до десяти источников света
@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel Глубина цвета BMP {_1}: допустимо 1, 4, 8, 16, 24 или 32 бита на пиксель
@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer Ошибка кодирования BMP: {width} × {height} пикселей не соответствует формату или буферу пикселей
@msg GIF encoding failed: the palette image is malformed Ошибка кодирования GIF: изображение с палитрой имеет неверную структуру
@msg GIF encoding failed: {e} Ошибка кодирования GIF: {e}
@msg JPEG colour profile: {e} Цветовой профиль JPEG: {e}
@msg JPEG encoding failed: pixel buffer doesn't match the image size Ошибка кодирования JPEG: буфер пикселей не соответствует размеру изображения
@msg JPEG encoding failed: {e} Ошибка кодирования JPEG: {e}
@msg PNG encoding failed: pixel buffer doesn't match the image size Ошибка кодирования PNG: буфер пикселей не соответствует размеру изображения
@msg PNG encoding failed: the palette image is malformed Ошибка кодирования PNG: изображение с палитрой имеет неверную структуру
@msg PNG encoding failed: {e} Ошибка кодирования PNG: {e}
@msg PNG encoding failed: {width} × {height} pixels is not a valid image size Ошибка кодирования PNG: {width} × {height} пикселей — недопустимый размер изображения
@msg PSD colour profile: {e} Цветовой профиль PSD: {e}
@msg PSD encoding failed: the pixel buffer doesn't match the image size Ошибка кодирования PSD: буфер пикселей не соответствует размеру изображения
@msg Revolve profile contains invalid coordinates Профиль вращения содержит недопустимые координаты
@msg Revolve profile is too complex Профиль вращения слишком сложен
@msg Revolve projection is not finite Проекция вращения не является конечной
@msg Revolve requires finite profile coordinates within 100 million points Для вращения нужны конечные координаты профиля в пределах 100 миллионов пунктов
@msg Revolve surface is too complex; reduce Segments or simplify the profile Поверхность вращения слишком сложна; уменьшите «Сегменты» или упростите профиль
@msg Revolve surface is too complex; simplify the profile Поверхность вращения слишком сложна; упростите профиль
@msg Revolve visibility cannot split the projected boundary Расчёт видимости вращения не может разделить спроецированную границу
@msg Revolve visibility creates too many fragments Расчёт видимости вращения создаёт слишком много фрагментов
@msg Revolve visibility creates too many vector anchors Расчёт видимости вращения создаёт слишком много векторных опорных точек
@msg Revolve visibility exceeds its geometry work limit Расчёт видимости вращения превышает свой предел геометрических вычислений
@msg Revolve visibility exceeds its spatial index limit Расчёт видимости вращения превышает свой предел пространственного индекса
@msg Revolve visibility has too many boundary crossings В расчёте видимости вращения слишком много пересечений границы
@msg Revolve visibility has too many input anchors В расчёте видимости вращения слишком много входных опорных точек
@msg Revolve visibility requires convex cap sectors Для расчёта видимости вращения нужны выпуклые секторы торцов
@msg Revolve visibility requires finite projected faces Для расчёта видимости вращения нужны конечные спроецированные грани
@msg SVG options: {e} Параметры SVG: {e}
@msg Save for Web settings: {e} Параметры «Сохранить для web»: {e}
@msg TIFF colour profile: {e} Цветовой профиль TIFF: {e}
@msg TIFF encoding failed: the pixel buffer doesn't match the image size Ошибка кодирования TIFF: буфер пикселей не соответствует размеру изображения
@msg TIFF encoding failed: {e} Ошибка кодирования TIFF: {e}
@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Глубина цвета Targa {_1}: допустимо 16, 24 или 32 бита на пиксель
@msg Targa encoding failed: the pixel buffer doesn't match the image size Ошибка кодирования Targa: буфер пикселей не соответствует размеру изображения
@msg The 3D projection is not finite 3D-проекция не является конечной
@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D-тень слишком сложна; отключите «Тени» или упростите графику
@msg The shape contains invalid coordinates Фигура содержит недопустимые координаты
@msg Too many Revolve faces for visibility evaluation Слишком много граней вращения для расчёта видимости
@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft читает из файла обмена образцами не более {ASE_MAX_ENTRIES} цветов и групп, а в этой библиотеке их {entries}
@msg WebP encoding failed: {e} Ошибка кодирования WebP: {e}
@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why}
@msg `answers[{i}]` {e} `answers[{i}]` {e}
@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` принимает значения smallThumb, mediumThumb, largeThumb, smallList или largeList
@msg a line can't run from a point to itself линия не может идти от точки к самой себе
@msg a line needs at least two points линии нужны хотя бы две точки
@msg a number cannot be read не удаётся прочитать число
@msg a swatch exchange block is too long блок файла обмена образцами слишком длинный
@msg a swatch exchange name is too long имя в файле обмена образцами слишком длинное
@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels размер изображения {sw} × {sh} нельзя изменить до {width} × {height} пикселей
@msg a {width} × {height} image is too large изображение {width} × {height} слишком велико
@msg a {width} × {height} image: {e} изображение {width} × {height}: {e}
@msg an image could not be written: {e} не удалось записать изображение: {e}
@msg an image that couldn't be read was left out изображение, которое не удалось прочитать, пропущено
@msg can't open “{name}”: {e} не удаётся открыть «{name}»: {e}
@msg cannot align zip entries to {align} bytes не удаётся выровнять записи zip по {align} байт
@msg cannot open the automation {kind} root `{_1}`: {e} не удаётся открыть корневую папку автоматизации ({kind}) `{_1}`: {e}
@msg cannot read profile: {0} не удаётся прочитать профиль: {0}
@msg color book version {version} isn't supported (only 1) версия цветовой книги {version} не поддерживается (только 1)
@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) цветовые книги в цветовом пространстве {other} не поддерживаются (только RGB, CMYK и Lab)
@msg file name too long to zip: {_1} имя файла слишком длинное для zip: {_1}
@msg file name too long to zip: {name} имя файла слишком длинное для zip: {name}
@msg freeform line {i}: {e} произвольная линия {i}: {e}
@msg freeform point {i}: {e} произвольная точка {i}: {e}
@msg give `{key}` or `{other}`, not both укажите `{key}` или `{other}`, но не то и другое
@msg grayscale profiles can't be working spaces yet профили оттенков серого пока не могут быть рабочими пространствами
@msg input contains NaN or infinite coordinates входные данные содержат координаты NaN или бесконечные координаты
@msg input path could not be closed не удалось замкнуть входной контур
@msg invalid ICC profile: {0} недопустимый профиль ICC: {0}
@msg invalid scale {scale} недопустимый масштаб {scale}
@msg its localized names take more than {_1} KB его локализованные имена занимают более {_1} КБ
@msg its objects are nested too deep ({e}) его объекты вложены слишком глубоко ({e})
@msg its tag table lists the `{_1}` tag more than once в его таблице тегов тег `{_1}` указан более одного раза
@msg not a graphic style library: {e} не библиотека графических стилей: {e}
@msg not a swatch library (.vcswatches or .gpl) не библиотека образцов (.vcswatches или .gpl)
@msg not a swatch library (format `{_1}`) не библиотека образцов (формат `{_1}`)
@msg not a swatch library: {e} не библиотека образцов: {e}
@msg not an action set file ({e}) не файл набора действий ({e})
@msg profile colour space {other} is not RGB, CMYK or Gray цветовое пространство профиля {other} не является RGB, CMYK или оттенками серого
@msg regular expression too large регулярное выражение слишком велико
@msg script file is not UTF-8 text: {value} файл скрипта не является текстом UTF-8: {value}
@msg stop {i}: {e} точка перехода {i}: {e}
@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) версия файла обмена образцами {major}.{minor} не поддерживается (только 1.x)
@msg the color book holds more than {ASE_MAX_ENTRIES} colors в цветовой книге больше {ASE_MAX_ENTRIES} цветов
@msg the color book is cut short цветовая книга обрезана
@msg the color book's names take more than {_1} MB имена цветовой книги занимают более {_1} МБ
@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution изображение имело бы размер {w} × {h} пикселей; растровый экспорт ограничен {MAX_RASTER_SIDE} пикселями по стороне и {_1} мегапикселями: уменьшите масштаб или разрешение
@msg the optimised image can't be read back: {e} оптимизированное изображение не удаётся прочитать обратно: {e}
@msg the pixel buffer doesn't match the image size буфер пикселей не соответствует размеру изображения
@msg the preview: {e} изображение для просмотра: {e}
@msg the request may not read the New Document profiles folder запросу не разрешено читать папку профилей нового документа
@msg the shapes are too degenerate to combine фигуры слишком вырождены для объединения
@msg the shapes cross themselves and each other too often to combine фигуры слишком часто пересекают себя и друг друга для объединения
@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups в файле обмена образцами больше {ASE_MAX_ENTRIES} цветов и групп
@msg the swatch exchange file is cut short файл обмена образцами обрезан
@msg the {_1} plate can't be read: {e} не удаётся прочитать пластину {_1}: {e}
@msg this binary DXF file is damaged or cut short этот двоичный файл DXF повреждён или обрезан
@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that это чертёж DWG, который невозможно прочитать: сохраните его из приложения CAD как DXF и откройте его
@msg too many classes слишком много классов
@msg too many groups слишком много групп
@msg too many layers for PSD (at most 32767) слишком много слоёв для PSD (не более 32767)
@msg too many swatch exchange blocks слишком много блоков в файле обмена образцами
@msg unknown colour profile: {0} неизвестный цветовой профиль: {0}
@msg {_1} clashes with a built-in profile name {_1} совпадает с именем встроенного профиля
@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} пикселей — слишком большой размер для GIF (не более 65535 пикселей по стороне): уменьшите разрешение
@msg {_1}: no usable transform to/from sRGB {_1}: нет пригодного преобразования в sRGB или из sRGB
@msg {name} is not a CMYK profile {name} — не профиль CMYK
@msg {name} is not an RGB profile {name} — не профиль RGB
@msg {path}: could not be read {path}: не удалось прочитать
@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для JPEG (не более 65535 пикселей по стороне): уменьшите разрешение
@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для Targa (не более 65535 пикселей по стороне): уменьшите разрешение
@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} пикселей — слишком большой размер для PSD (не более {MAX_SIDE} пикселей по стороне): уменьшите разрешение
Can't render this file because it is too large.
+9 -3
View File
@@ -693,6 +693,8 @@ font-technology Variable Variabla
Crop Image Beskär bild
Image Tools Bildverktyg
Remove Background… Ta bort bakgrund…
Draw (Pencil) Rita (blyertspenna)
Place Image Placera bild
Remove Background Ta bort bakgrund
Background Bakgrund
Edge Color Kantfärg
@@ -4397,6 +4399,7 @@ axis Both Båda
@msg `select` must be true or false `select` måste vara true eller false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` och `maxAnchors` måste vara heltal
@msg `{key}` must be a number not below 0 `{key}` måste vara ett tal som inte är under 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` måste vara fyra tal som inte är under 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` måste vara [x, y, width, height]: fyra ändliga tal, där bredd och höjd inte är negativa
@msg `{min_key}` is greater than `{max_key}` `{min_key}` är större än `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` ange minst en av `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5235,6 +5238,7 @@ effect Soft Mjuk
VectorCraft Title Bar on Every Desktop VectorCrafts namnlist på alla skrivbord
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Miljövariabeln VECTORCRAFT_TITLEBAR väljer namnlisten så länge den är angiven.
System Title Bar takes precedence over this option while it is on. Systemets namnlist har företräde framför det här alternativet så länge den är på.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Så länge VectorCrafts namnlist på alla skrivbord är av ritar det här skrivbordet fönstrets namnlist, oavsett om det här alternativet är på eller av.
Value Axis Värdeaxel
On Left Side På vänster sida
On Right Side På höger sida
@@ -6580,6 +6584,8 @@ axis Y Axis Y-axel
Action Options… Alternativ för åtgärd…
Action Options Alternativ för åtgärd
Playback Options… Uppspelningsalternativ…
Button Mode Knappläge
No actions. Inga åtgärder.
Insert Stop… Infoga stopp…
Insert Stop Infoga stopp
Record Stop Spela in stopp
@@ -7545,7 +7551,7 @@ digits Hindi Hindi
@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` är längre än {MAX_TEXT} byte
@msg `{what}` must be text `{what}` måste vara text
@msg `align` is start, center or end `align` är start, center eller end
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` måste vara en array av par [delbana, ankarpunkt]
@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` måste vara en array av par [subpath, anchor]
@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` måste lista ankarpunktsindex (0, 1, 2…), inte {k}
@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` måste lista ankarpunktsindex (0, 1, 2…), inte {v}
@msg `artboards` must be a list of artboard indices `artboards` måste vara en lista med ritytsindex
@@ -8070,7 +8076,7 @@ digits Hindi Hindi
@msg instruction budget instruktionsbudget
@msg invalid `color` ogiltig `color`
@msg invalid anchor index {v}: expected a non-negative integer ogiltigt ankarindex {v}: ett icke-negativt heltal förväntades
@msg invalid anchor reference {v}: expected [subpath, anchor] ogiltig ankarreferens {v}: [delbana, ankare] förväntades
@msg invalid anchor reference {v}: expected [subpath, anchor] ogiltig ankarreferens {v}: [subpath, anchor] förväntades
@msg invalid angle ogiltig vinkel
@msg invalid brush: {e} ogiltig pensel: {e}
@msg invalid color ogiltig färg
@@ -8159,7 +8165,7 @@ digits Hindi Hindi
@msg missing `value` `value` saknas
@msg missing anchor ankare saknas
@msg missing anchors ankare saknas
@msg missing anchors [[subpath, anchor]…] ankare saknas [[delbana, ankare]…]
@msg missing anchors [[subpath, anchor]…] ankare saknas [[subpath, anchor]…]
@msg missing array `{key}` arrayen `{key}` saknas
@msg missing case skiftläge saknas
@msg missing color färg saknas
Can't render this file because it is too large.
+113
View File
@@ -3,6 +3,20 @@ use super::*;
const ZH: fn() -> Lang = || Lang::from_code("zh-hant").expect("zh-hant registered");
#[test]
fn gujarati_resolves_locales_and_takes_the_singular_for_zero_and_one() {
// The catalog is present even when this web build cannot display it.
let gu = Lang(LANGUAGES.iter().find(|l| l.code == "gu").expect("gu registered"));
let selectable = gu.0.available().then_some(gu);
assert_eq!(Lang::from_code("gu"), selectable);
for tag in ["gu", "gu-IN", "gu_IN.UTF-8", "GU-in", "gu-Gujr-IN"] {
assert_eq!(lang_from_tag(tag), selectable, "{tag}");
}
assert_eq!(gu.name(), "ગુજરાતી");
assert_eq!([0, 1, 2, 21].map(gu.0.plural), [0, 0, 1, 1]);
assert_eq!(tr(gu, "Save"), "સાચવો");
}
#[test]
fn hindi_resolves_locales_and_uses_zero_one_plurals() {
let hi = Lang::from_code("hi").expect("hi registered");
@@ -209,6 +223,17 @@ fn tags_map_to_languages() {
assert_eq!(lang_from_tag("_"), None);
}
#[test]
fn gujarati_web_selection_requires_a_bundled_interface_font() {
let gu = LANGUAGES.iter().find(|l| l.code == "gu").expect("Gujarati catalog");
assert!(gu.available_with_fonts(false, false), "native installed-font fallback");
assert!(!gu.available_with_fonts(true, false), "web must not offer missing glyphs");
assert!(gu.available_with_fonts(true, true));
assert!(LANGUAGES[0].available_with_fonts(true, false), "English fallback");
assert_eq!(Lang::from_code("gu").is_some(), gu.available());
assert_eq!(Lang::for_picker().iter().any(|l| l.code() == "gu"), gu.available());
}
/// The Bosnian, Georgian, Ukrainian, Hungarian, Korean, Vietnamese, Swedish and Catalan tags of
/// [`tags_map_to_languages`].
fn other_tags_map_to_languages() {
@@ -823,6 +848,11 @@ fn is_devanagari(c: char) -> bool {
matches!(c as u32, 0x0900..=0x097F | 0x1CD0..=0x1CFF | 0xA8E0..=0xA8FF)
}
#[cfg(feature = "interface-fonts")]
fn is_gujarati(c: char) -> bool {
matches!(c as u32, 0x0A80..=0x0AFF)
}
/// The UI fonts draw every character the catalogs use, in every family the UI draws text with
/// (the web build has no system fonts to fall back to). Chinese and Japanese characters come from
/// the craft-fonts build input, which is optional: without it they are left to the installed
@@ -836,6 +866,7 @@ fn bundled_fonts_cover_the_catalogs() {
let japanese_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese());
let georgian_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_georgian());
let devanagari_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Deva"));
let gujarati_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&"Gujr"));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
@@ -859,6 +890,7 @@ fn bundled_fonts_cover_the_catalogs() {
&& (cjk_checked || !is_cjk(*c))
&& (georgian_ui_font || !is_georgian(*c))
&& (devanagari_ui_font || !is_devanagari(*c))
&& (gujarati_ui_font || !is_gujarati(*c))
})
.collect();
for family in &families {
@@ -4030,6 +4062,87 @@ fn translated_messages_keep_the_command_ids_their_english_names() {
assert!(missing.is_empty(), "{} rows drop a command id their English names:\n{}", missing.len(), missing.join("\n"));
}
/// Whether `word` is an ASCII name a command reads: a letter followed by letters, digits or
/// underscores.
fn is_ascii_name(word: &str) -> bool {
word.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && word.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// The field names and accepted values that `s` spells out of its backticked spans: each name of a
/// comma-separated list in square brackets, such as `[x, y, width, height]` or
/// `[[subpath, anchor]…]`; each `a|b|c` list of values, whole; and the values an `unknown …` or
/// `bad model …` message lists in parentheses, such as `(none, standard or wedges)`, up to the first
/// item that is not a single name, so `(high, medium, low or a saved one: see …)` gives `high`,
/// `medium` and `low`, and `(or give its ids)` gives none.
fn field_names_and_values(s: &str) -> Vec<String> {
let outside = s.split('`').step_by(2).collect::<Vec<_>>().join(" ");
let mut found = Vec::new();
for piece in outside.split('[').skip(1) {
let Some(end) = piece.find(']') else { continue };
let names: Vec<&str> = piece[..end].split(',').map(str::trim).collect();
if names.len() > 1 && names.iter().all(|name| is_ascii_name(name)) {
found.extend(names.into_iter().map(str::to_string));
}
}
let lists = outside.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '|'));
found.extend(lists.filter(|w| w.contains('|') && w.split('|').all(is_ascii_name)).map(str::to_string));
for (at, _) in outside.match_indices('(') {
let before = &outside[..at];
if !(holds_ascii_word(before, "unknown") || before.contains("bad model")) {
continue;
}
let Some(end) = outside[at..].find(')') else { continue };
let items = outside[at + 1..at + end].split(", ").flat_map(|item| item.split(" or "));
let values: Vec<&str> = items.map(str::trim).take_while(|item| is_ascii_name(item)).collect();
if values.len() > 1 {
found.extend(values.into_iter().map(str::to_string));
}
}
found
}
#[test]
fn field_names_and_values_finds_bracketed_names_and_listed_values() {
assert_eq!(field_names_and_values("`{key}` must be [x, y, width, height]: four numbers"), ["x", "y", "width", "height"]);
assert_eq!(field_names_and_values("missing anchors [[subpath, anchor]…]"), ["subpath", "anchor"]);
assert_eq!(field_names_and_values("arrowScale must be [start, end]"), ["start", "end"]);
assert_eq!(field_names_and_values("unknown pieLegend {v} (none, standard or wedges)"), ["none", "standard", "wedges"]);
assert_eq!(field_names_and_values("unknown side `{other}` (left|right|top|bottom)"), ["left|right|top|bottom"]);
assert_eq!(field_names_and_values("bad model {other} (keep, new or document)"), ["keep", "new", "document"]);
assert_eq!(
field_names_and_values("unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list)"),
["high", "medium", "low"]
);
assert_eq!(field_names_and_values("unknown object (or give its ids)"), Vec::<String>::new());
assert_eq!(field_names_and_values("unknown color `{c}` (#rrggbb or a preset name)"), Vec::<String>::new());
assert_eq!(field_names_and_values("unknown resolution (72 screen, 150 medium, 300 high)"), Vec::<String>::new());
assert_eq!(field_names_and_values("the art is [1, 2] (left, right) and `[a, b]`"), Vec::<String>::new());
}
/// A translated status or error message keeps each field name and accepted value its English text
/// spells out (see [`field_names_and_values`]), as the English spells it: the message tells a user
/// what to pass, and a command reads only the English names and values.
#[test]
fn translated_messages_keep_the_field_names_and_values_their_english_lists() {
let mut missing = Vec::new();
for l in &LANGUAGES {
let (entries, _) = parse_entries(l.source);
for (ctx, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg" || ctx == "@msgplural") {
let plural = ctx == "@msgplural";
let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() };
let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] };
for form in forms {
for name in field_names_and_values(english) {
if !holds_ascii_word(form, &name) {
missing.push(format!("{}: {name} in {src:?} -> {form:?}", l.code));
}
}
}
}
}
assert!(missing.is_empty(), "{} rows drop a field name or value their English lists:\n{}", missing.len(), missing.join("\n"));
}
/// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists
/// the messages each one lacks.
const COMPLETE_MESSAGES: &[&str] = &[
+11 -5
View File
@@ -685,6 +685,8 @@ font-technology Variable Değişken
Crop Image Resmi Kırp
Image Tools Resim Araçları
Remove Background… Arka Planı Kaldır…
Draw (Pencil) Çiz (Kurşun Kalem)
Place Image Resim Yerleştir
Remove Background Arka Planı Kaldır
Background Arka Plan
Edge Color Kenar Rengi
@@ -4246,6 +4248,7 @@ axis Both Her İkisi
@msg `select` must be true or false `select` true veya false olmalıdır
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` ve `maxAnchors` tam sayı olmalıdır
@msg `{key}` must be a number not below 0 `{key}` 0'dan küçük olmayan bir sayı olmalıdır
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` 0'dan küçük olmayan dört sayı olmalıdır: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` [x, y, width, height] olmalıdır: dört sonlu sayı, genişlik ve yükseklik negatif olmamalı
@msg `{min_key}` is greater than `{max_key}` `{min_key}`, `{max_key}` değerinden büyük
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` şunlardan en az birini verin: `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -4702,11 +4705,11 @@ axis Both Her İkisi
@msg unknown effect `{id}` bilinmeyen etki `{id}`
@msg unknown format bilinmeyen format
@msg unknown format `{f}` (Save writes {_1}) bilinmeyen format `{f}` (Kaydet, {_1} yazar)
@msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (noktalar, çizgiler)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, konik, elmas, serbest biçimli)
@msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (points, lines)
@msg unknown gradient kind `{k}` (linear, radial, conic, diamond, freeform) bilinmeyen gradyan türü `{k}` (linear, radial, conic, diamond, freeform)
@msg unknown highlight `{h}` bilinmeyen vurgu `{h}`
@msg unknown option `{path}.{k}` bilinmeyen seçenek `{path}.{k}`
@msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at)
@msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (preserve, simulate veya discard)
@msg unknown panel `{raw}` bilinmeyen panel `{raw}`
@msg panels can't be shown in Presentation Mode Sunum Modunda paneller gösterilemez
@msg unknown preference `{key}` bilinmeyen tercih `{key}`
@@ -4725,11 +4728,11 @@ axis Both Her İkisi
@msg `{key}` must be an object `{key}` bir nesne olmalıdır
@msg `prefs.import` runs on its own, not as a batch step `prefs.import` tek başına çalışır, toplu iş adımı olarak değil
@msg unknown preset (one of {_1}) bilinmeyen ön ayar ({_1}'den biri)
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) bilinmeyen ön ayar `{name}` (yüksek, orta, düşük veya kaydedilmiş bir ayar: bkz. flattener.presets.list)
@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) bilinmeyen ön ayar `{name}` (high, medium, low veya kaydedilmiş bir ayar: bkz. flattener.presets.list)
@msg unknown preset `{name}` (see flattener.presets.list) bilinmeyen ön ayar `{name}` (bkz. flattener.presets.list)
@msg unknown preset `{name}` (see webExport.presets.list) bilinmeyen ön ayar `{name}` (bkz. webExport.presets.list)
@msg unknown preset `{name}`: not a built-in or saved preset (see imageTrace.presets) bilinmeyen ön ayar `{name}`: yerleşik ya da kayıtlı bir ön ayar değil (bkz. imageTrace.presets)
@msg unknown side `{other}` (left|right|top|bottom) bilinmeyen taraf `{other}` (sol|sağ|üst|alt)
@msg unknown side `{other}` (left|right|top|bottom) bilinmeyen taraf `{other}` (left|right|top|bottom)
@msg unknown tool `{tool}` bilinmeyen araç `{tool}`
@msg unknown tool `{t}` bilinmeyen araç `{t}`
@msg unknown tool or command `{key}` bilinmeyen araç veya komut `{key}`
@@ -5038,6 +5041,7 @@ effect Soft Yumuşak
VectorCraft Title Bar on Every Desktop Her Masaüstünde VectorCraft Başlık Çubuğu
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. VECTORCRAFT_TITLEBAR ortam değişkeni ayarlı olduğu sürece başlık çubuğunu o seçer.
System Title Bar takes precedence over this option while it is on. Sistem Başlık Çubuğu açık olduğu sürece bu seçeneğe göre önceliklidir.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Her Masaüstünde VectorCraft Başlık Çubuğu kapalı olduğu sürece, bu seçenek açık da olsa kapalı da olsa pencerenin başlık çubuğunu bu masaüstü çizer.
Value Axis Değer Ekseni
On Left Side Sol Tarafta
On Right Side Sağ Tarafta
@@ -6556,6 +6560,8 @@ axis Y Axis Y ekseni
Action Options… Eylem Seçenekleri…
Action Options Eylem Seçenekleri
Playback Options… Oynatma Seçenekleri…
Button Mode Düğme Modu
No actions. Eylem yok.
Insert Stop… Durak Ekle…
Insert Stop Durak Ekle
Record Stop Durak Kaydet
Can't render this file because it contains an unexpected character in line 5 and column 25.
+6
View File
@@ -699,6 +699,8 @@ font-technology Variable Варіативні
Crop Image Обрізати зображення
Image Tools Інструменти зображення
Remove Background… Видалити фон…
Draw (Pencil) Малювати (Олівець)
Place Image Помістити зображення
Remove Background Видалити фон
Background Фон
Edge Color Колір краю
@@ -4525,6 +4527,7 @@ axis Both Обидві
@msg `select` must be true or false `select` має бути true або false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` і `maxAnchors` мають бути цілими числами
@msg `{key}` must be a number not below 0 `{key}` має бути числом, не меншим за 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` має бути чотирма числами, не меншими за 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` має бути [x, y, width, height]: чотири скінченні числа, ширина й висота невід’ємні
@msg `{min_key}` is greater than `{max_key}` `{min_key}` більше за `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` укажіть принаймні один із параметрів `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5359,6 +5362,7 @@ axis Both Обидві
VectorCraft Title Bar on Every Desktop Рядок заголовка VectorCraft на будь-якій стільниці
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Поки змінну середовища VECTORCRAFT_TITLEBAR задано, рядок заголовка вибирає вона.
System Title Bar takes precedence over this option while it is on. Поки ввімкнено «Системний рядок заголовка», він має пріоритет над цим параметром.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Поки «Рядок заголовка VectorCraft на будь-якій стільниці» вимкнено, ця стільниця малює рядок заголовка вікна незалежно від того, чи ввімкнено цей параметр.
Value Axis Вісь значень
On Left Side Ліворуч
On Right Side Праворуч
@@ -6206,6 +6210,8 @@ axis Y Axis Вісь Y
Action Options… Параметри дії…
Action Options Параметри дії
Playback Options… Параметри відтворення…
Button Mode Режим кнопок
No actions. Нема дій.
Insert Stop… Вставити зупинку…
Insert Stop Вставити зупинку
Record Stop Записати зупинку
Can't render this file because it is too large.
+6
View File
@@ -806,6 +806,8 @@ font-technology Variable Biến thiên
Crop Image Cắt xén ảnh
Image Tools Công cụ hình ảnh
Remove Background… Xóa nền…
Draw (Pencil) Vẽ (Bút chì)
Place Image Đặt hình ảnh
Remove Background Xóa nền
Background Nền
Edge Color Màu cạnh
@@ -4036,6 +4038,7 @@ mark-style Japanese Kiểu Nhật
VectorCraft Title Bar on Every Desktop Thanh tiêu đề VectorCraft trên mọi màn hình nền
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Khi biến môi trường VECTORCRAFT_TITLEBAR được đặt, biến này chọn thanh tiêu đề.
System Title Bar takes precedence over this option while it is on. Khi Thanh tiêu đề hệ thống được bật, tùy chọn đó được ưu tiên hơn tùy chọn này.
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. Khi Thanh tiêu đề VectorCraft trên mọi màn hình nền đang tắt, màn hình nền này vẽ thanh tiêu đề của cửa sổ dù tùy chọn này bật hay tắt.
Tab Tab
Tab Stops Vị trí tab
Table Bảng
@@ -4573,6 +4576,7 @@ theme Appearance Giao diện
@msg `select` must be true or false `select` phải là true hoặc false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` và `maxAnchors` phải là số nguyên
@msg `{key}` must be a number not below 0 `{key}` phải là một số không nhỏ hơn 0
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` phải là bốn số không nhỏ hơn 0: [tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` phải là [x, y, width, height]: bốn số hữu hạn, chiều rộng và chiều cao không âm
@msg `{min_key}` is greater than `{max_key}` `{min_key}` lớn hơn `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` hãy cung cấp ít nhất một trong `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -6207,6 +6211,8 @@ axis Y Axis Trục Y
Action Options… Tùy chọn hành động…
Action Options Tùy chọn hành động
Playback Options… Tùy chọn phát lại…
Button Mode Chế độ nút
No actions. Không có hành động.
Insert Stop… Chèn điểm dừng…
Insert Stop Chèn điểm dừng
Record Stop Ghi điểm dừng
Can't render this file because it contains an unexpected character in line 204 and column 5.
+122
View File
@@ -650,6 +650,8 @@ antiAlias Sharp 锐利
Crop Image 裁剪图像
Image Tools 图像工具
Remove Background… 移除背景…
Draw (Pencil) 绘制 (铅笔)
Place Image 置入图像
Remove Background 移除背景
Background 背景
Edge Color 边缘颜色
@@ -4694,6 +4696,7 @@ imageTrace Outline 轮廓
@msg `select` must be true or false `select` 必须是 true 或 false
@msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` 和 `maxAnchors` 必须是整数
@msg `{key}` must be a number not below 0 `{key}` 必须是不小于 0 的数字
@msg `{key}` must be four numbers not below 0: [tl, tr, br, bl] `{key}` 必须是四个不小于 0 的数字:[tl, tr, br, bl]
@msg `{key}` must be [x, y, width, height]: four finite numbers, width and height not negative `{key}` 必须是 [x, y, width, height]:四个有限数字,宽度和高度不能为负
@msg `{min_key}` is greater than `{max_key}` `{min_key}` 大于 `{max_key}`
@msg give at least one of `name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors` 请至少提供以下其中之一:`name`, `kind`, `text`, `within`, `intersects`, `minWidth`, `maxWidth`, `minHeight`, `maxHeight`, `minThickness`, `maxThickness`, `minArea`, `maxArea`, `minAnchors`, `maxAnchors`
@@ -5659,6 +5662,7 @@ effect Vertical 垂直
VectorCraft Title Bar on Every Desktop 在所有桌面上使用 VectorCraft 标题栏
The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. 设置了环境变量 VECTORCRAFT_TITLEBAR 时,由它选择标题栏。
System Title Bar takes precedence over this option while it is on. 开启系统标题栏时,它优先于此选项。
This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off. 未开启“在所有桌面上使用 VectorCraft 标题栏”时,无论此选项是否开启,此桌面都会绘制窗口的标题栏。
Value Axis 数值轴
On Left Side 左侧
On Right Side 右侧
@@ -6439,6 +6443,8 @@ axis Y Axis Y 轴
Action Options… 动作选项…
Action Options 动作选项
Playback Options… 播放选项…
Button Mode 按钮模式
No actions. 没有动作。
Insert Stop… 插入停止…
Insert Stop 插入停止
Record Stop 记录停止
@@ -9148,3 +9154,119 @@ breakText Glyphs 字形
@msg {what} can't be intertwined {what} 不能交织
@msg {what} doesn't fit the paper with its bleed and marks {what} 连同出血和标记在纸张上放不下
@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} 会打印成超过 {MAX_TILES} 个拼贴:请将其缩小
# Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import.
@msg 3D effects require finite coordinates within 100 million points 3D 效果需要 1 亿点以内的有限坐标
@msg 3D effects require one to ten lights 3D 效果需要一到十个光源
@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP 位深度 {_1}:须为每像素 1、4、8、16、24 或 32 位
@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP 编码失败:{width} × {height} 像素与该格式或像素缓冲区不符
@msg GIF encoding failed: the palette image is malformed GIF 编码失败:调板图像格式错误
@msg GIF encoding failed: {e} GIF 编码失败:{e}
@msg JPEG colour profile: {e} JPEG 颜色配置文件:{e}
@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG 编码失败:像素缓冲区与图像尺寸不符
@msg JPEG encoding failed: {e} JPEG 编码失败:{e}
@msg PNG encoding failed: pixel buffer doesn't match the image size PNG 编码失败:像素缓冲区与图像尺寸不符
@msg PNG encoding failed: the palette image is malformed PNG 编码失败:调板图像格式错误
@msg PNG encoding failed: {e} PNG 编码失败:{e}
@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG 编码失败:{width} × {height} 像素不是有效的图像尺寸
@msg PSD colour profile: {e} PSD 颜色配置文件:{e}
@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD 编码失败:像素缓冲区与图像尺寸不符
@msg Revolve profile contains invalid coordinates 回转轮廓包含无效坐标
@msg Revolve profile is too complex 回转轮廓过于复杂
@msg Revolve projection is not finite 回转投影不是有限值
@msg Revolve requires finite profile coordinates within 100 million points 回转需要 1 亿点以内的有限轮廓坐标
@msg Revolve surface is too complex; reduce Segments or simplify the profile 回转表面过于复杂;请减少分段数或简化轮廓
@msg Revolve surface is too complex; simplify the profile 回转表面过于复杂;请简化轮廓
@msg Revolve visibility cannot split the projected boundary 回转可见性计算无法分割投影边界
@msg Revolve visibility creates too many fragments 回转可见性计算产生的碎片过多
@msg Revolve visibility creates too many vector anchors 回转可见性计算产生的矢量锚点过多
@msg Revolve visibility exceeds its geometry work limit 回转可见性计算超出几何运算上限
@msg Revolve visibility exceeds its spatial index limit 回转可见性计算超出空间索引上限
@msg Revolve visibility has too many boundary crossings 回转可见性计算中的边界交叉过多
@msg Revolve visibility has too many input anchors 回转可见性计算的输入锚点过多
@msg Revolve visibility requires convex cap sectors 回转可见性计算需要凸形端盖扇区
@msg Revolve visibility requires finite projected faces 回转可见性计算需要有限的投影面
@msg SVG options: {e} SVG 选项:{e}
@msg Save for Web settings: {e} 存储为 Web 所用格式设置:{e}
@msg TIFF colour profile: {e} TIFF 颜色配置文件:{e}
@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF 编码失败:像素缓冲区与图像尺寸不符
@msg TIFF encoding failed: {e} TIFF 编码失败:{e}
@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa 位深度 {_1}:须为每像素 16、24 或 32 位
@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa 编码失败:像素缓冲区与图像尺寸不符
@msg The 3D projection is not finite 3D 投影不是有限值
@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D 阴影过于复杂;请关闭阴影或简化图稿
@msg The shape contains invalid coordinates 形状包含无效坐标
@msg Too many Revolve faces for visibility evaluation 回转的面过多,无法进行可见性计算
@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft 最多从色板交换文件中读取 {ASE_MAX_ENTRIES} 个颜色和组,而此库有 {entries} 个
@msg WebP encoding failed: {e} WebP 编码失败:{e}
@msg [{j}] `{_1}`: {why} [{j}] `{_1}`:{why}
@msg `answers[{i}]` {e} `answers[{i}]` {e}
@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` 必须是 smallThumb、mediumThumb、largeThumb、smallList 或 largeList
@msg a line can't run from a point to itself 曲线不能从一个点连到该点自身
@msg a line needs at least two points 曲线至少需要两个点
@msg a number cannot be read 无法读取数字
@msg a swatch exchange block is too long 色板交换文件中的某个块过长
@msg a swatch exchange name is too long 色板交换文件中的某个名称过长
@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels 无法将 {sw} × {sh} 的图像调整为 {width} × {height} 像素
@msg a {width} × {height} image is too large {width} × {height} 的图像过大
@msg a {width} × {height} image: {e} {width} × {height} 的图像:{e}
@msg an image could not be written: {e} 无法写入某个图像:{e}
@msg an image that couldn't be read was left out 已略去一个无法读取的图像
@msg can't open “{name}”: {e} 无法打开“{name}”:{e}
@msg cannot align zip entries to {align} bytes 无法将 zip 条目对齐到 {align} 字节
@msg cannot open the automation {kind} root `{_1}`: {e} 无法打开自动化 {kind} 根目录 `{_1}`:{e}
@msg cannot read profile: {0} 无法读取配置文件:{0}
@msg color book version {version} isn't supported (only 1) 不支持色谱版本 {version} (仅支持 1)
@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) 不支持色彩空间为 {other} 的色谱 (仅支持 RGB、CMYK 和 Lab)
@msg file name too long to zip: {_1} 文件名过长,无法放入 zip:{_1}
@msg file name too long to zip: {name} 文件名过长,无法放入 zip:{name}
@msg freeform line {i}: {e} 自由曲线 {i}:{e}
@msg freeform point {i}: {e} 自由点 {i}:{e}
@msg give `{key}` or `{other}`, not both 请提供 `{key}` 或 `{other}`,不要同时提供两者
@msg grayscale profiles can't be working spaces yet 灰度配置文件目前还不能用作工作空间
@msg input contains NaN or infinite coordinates 输入包含 NaN 或无穷大坐标
@msg input path could not be closed 无法闭合输入路径
@msg invalid ICC profile: {0} ICC 配置文件无效:{0}
@msg invalid scale {scale} 缩放比例 {scale} 无效
@msg its localized names take more than {_1} KB 其本地化名称超过 {_1} KB
@msg its objects are nested too deep ({e}) 其对象嵌套过深 ({e})
@msg its tag table lists the `{_1}` tag more than once 其标签表多次列出 `{_1}` 标签
@msg not a graphic style library: {e} 不是图形样式库:{e}
@msg not a swatch library (.vcswatches or .gpl) 不是色板库 (.vcswatches 或 .gpl)
@msg not a swatch library (format `{_1}`) 不是色板库 (格式为 `{_1}`)
@msg not a swatch library: {e} 不是色板库:{e}
@msg not an action set file ({e}) 不是动作组文件 ({e})
@msg profile colour space {other} is not RGB, CMYK or Gray 配置文件的色彩空间 {other} 不是 RGB、CMYK 或灰度
@msg regular expression too large 正则表达式过大
@msg script file is not UTF-8 text: {value} 脚本文件不是 UTF-8 文本:{value}
@msg stop {i}: {e} 色标 {i}:{e}
@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) 不支持色板交换文件版本 {major}.{minor} (仅支持 1.x)
@msg the color book holds more than {ASE_MAX_ENTRIES} colors 色谱包含的颜色超过 {ASE_MAX_ENTRIES} 种
@msg the color book is cut short 色谱数据不完整
@msg the color book's names take more than {_1} MB 色谱的名称超过 {_1} MB
@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution 图像将为 {w} × {h} 像素;栅格导出限制为每边 {MAX_RASTER_SIDE} 像素和 {_1} 百万像素:请降低缩放比例或分辨率
@msg the optimised image can't be read back: {e} 无法回读优化后的图像:{e}
@msg the pixel buffer doesn't match the image size 像素缓冲区与图像尺寸不符
@msg the preview: {e} 预览:{e}
@msg the request may not read the New Document profiles folder 此请求无权读取新建文档配置文件文件夹
@msg the shapes are too degenerate to combine 这些形状过于退化,无法合并
@msg the shapes cross themselves and each other too often to combine 这些形状的自身交叉和相互交叉过多,无法合并
@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups 色板交换文件包含的颜色和组超过 {ASE_MAX_ENTRIES} 个
@msg the swatch exchange file is cut short 色板交换文件不完整
@msg the {_1} plate can't be read: {e} 无法读取 {_1} 印版:{e}
@msg this binary DXF file is damaged or cut short 此二进制 DXF 文件已损坏或不完整
@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that 这是 DWG 图纸,无法读取:请在 CAD 应用中将其保存为 DXF,然后打开该文件
@msg too many classes 字符类过多
@msg too many groups 分组过多
@msg too many layers for PSD (at most 32767) 图层过多,PSD 无法容纳 (最多 32767 个)
@msg too many swatch exchange blocks 色板交换文件中的块过多
@msg unknown colour profile: {0} 未知的颜色配置文件:{0}
@msg {_1} clashes with a built-in profile name {_1} 与内置配置文件名称冲突
@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} 像素对 GIF 而言过大 (每边最多 65535 像素):请降低分辨率
@msg {_1}: no usable transform to/from sRGB {_1}:没有可用的与 sRGB 之间的转换
@msg {name} is not a CMYK profile {name} 不是 CMYK 配置文件
@msg {name} is not an RGB profile {name} 不是 RGB 配置文件
@msg {path}: could not be read {path}:无法读取
@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 JPEG 而言过大 (每边最多 65535 像素):请降低分辨率
@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 Targa 而言过大 (每边最多 65535 像素):请降低分辨率
@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} 像素对 PSD 而言过大 (每边最多 {MAX_SIDE} 像素):请降低分辨率
Can't render this file because it is too large.
File diff suppressed because it is too large Load Diff
+10 -4
View File
@@ -26,13 +26,13 @@ pub(crate) use export::{
export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named,
};
pub(crate) use open::report_installed;
pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, opened, show_opened};
pub(crate) use open::{EXPORT_PRESETS, import_presets, open_notes, open_with_on, opened, opened_after_notice, show_opened};
pub use open::{
MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document,
open_path, open_path_with, open_reporting, open_with, opens_as, place_bytes, recent_files, report_open_error,
};
#[cfg(test)]
pub(crate) use open::{open_file, opened_after_notice, opened_with_notes};
pub(crate) use open::{open_file, opened_with_notes};
pub(crate) use save::save_current;
pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf};
pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to};
@@ -171,14 +171,20 @@ mod tests {
app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap();
assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None));
// A PDF export of a knockout group and of type in a font that isn't installed.
// A PDF export of a knockout group whose soft masks would draw a brushed stroke, and of type
// in a font that isn't installed.
app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
let lower = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
let upper = app.run("shape.rectangle", json!({"x": 25, "y": 15, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("brush.apply", json!({"ids": [upper], "name": "3 pt. Round"})).unwrap();
app.run("object.setProps", json!({"ids": [lower, upper], "opacity": 50, "knockoutShape": true})).unwrap();
app.run("select.set", json!({"ids": [lower, upper]})).unwrap();
app.run("object.group", json!({})).unwrap();
app.run("transparency.set", json!({"knockout": "on"})).unwrap();
let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap();
let notes = notes_of(&r);
assert_eq!(notes.len(), 2, "{r}");
assert!(notes.iter().any(|n| n.contains("brushed strokes")), "{r}");
assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), &notes[0]));
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)));
}
+1 -1
View File
@@ -162,7 +162,7 @@ pub(crate) fn show_opened(app: &mut VectorcraftApp, notice: Option<String>, note
/// The notes of the open `r`, those of what reading the file left out (its document's
/// `import_losses`) first, each group in the order the import gave them.
fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec<String> {
pub(crate) fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec<String> {
let notes = notes_of(r);
let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok());
let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default();
+6
View File
@@ -235,6 +235,12 @@ pub struct VectorcraftApp {
/// icon and title. Set by the desktop app when it opens the window; `false` for embedders
/// without a window of their own.
pub desktop_frame: bool,
/// Linux and the BSDs: `VECTORCRAFT_TITLEBAR` is unset and the desktop would draw the window's
/// frame on this session with Preferences › User Interface › System Title Bar on or off while
/// VectorCraft Title Bar on Every Desktop is off, so the dialog says so under System Title Bar.
/// It stays `true` while VectorCraft Title Bar on Every Desktop gives the app the frame. Set by
/// the desktop app when it opens the window; `false` elsewhere.
pub desktop_keeps_frame: bool,
/// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`):
/// sent again only when it changes, so idle frames don't spam the backend.
pub(crate) last_window_title: String,
+2
View File
@@ -985,6 +985,8 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
crate::panels::actions::NEW_ACTION => crate::panels::actions::new_action(app, p),
crate::panels::actions::ACTION_OPTIONS => crate::panels::actions::action_options(app, p),
crate::panels::actions::PLAY_ACTION => crate::panels::actions::play_action(app, p),
crate::panels::actions::PLAY_AT => crate::panels::actions::play_at(app, p),
crate::panels::actions::BUTTON_MODE => crate::panels::actions::button_mode(app, p),
crate::panels::actions::PLAYBACK_OPTIONS => crate::panels::actions::playback_options(app, p),
crate::panels::actions::START_RECORDING => crate::panels::actions::record(app, p),
crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p),
+13 -1
View File
@@ -1484,7 +1484,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
crate::panels::actions::NEW_ACTION,
"New Action",
"",
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
"{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), the color of its button in Button Mode (ui.action.buttonMode). Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}",
),
(
crate::panels::actions::ACTION_OPTIONS,
@@ -1498,6 +1498,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{action: name, set?: name} play the Actions panel's action named action at the speed ui.action.playbackOptions sets; set names the set to look in (default: every set). Accelerated plays it as one undoable batch → what command.batch returns. Step by Step and Pause For play its first step now and the others one at a time, each its own undo step, until the last, ui.action.stop, or a step that comes due while a different document is active than after the step before → {results: [the first step's result], pending: the steps left}. An action with a stop, at any speed, plays up to the stop and waits until ui.action.continue or ui.action.stop; at Accelerated the steps between stops are one undoable batch each → {results, pending, atStop?: true when it already waits}. A name no action has, or one that more than one action in the sets looked in has, or an action played while another is still playing step by step or with pauses, is an error and nothing plays",
),
(
crate::panels::actions::PLAY_AT,
"Play Action",
"",
"{set: index, action: index} play the Actions panel's action at action in set (0 is the top set) as ui.action.play plays it, and return what ui.action.play returns. A click on an action's button in Button Mode runs this. A missing index, or one that is not a whole number or is past the last set or action, is an error and nothing plays",
),
(
crate::panels::actions::BUTTON_MODE,
"Button Mode",
"",
"{enabled?: bool} with true, the Actions panel shows every action of every set, in set order and then action order, as a button with its name in its Action Options color (no color: an ordinary button), without set names, steps or checkmarks, and a click on a button plays its action (ui.action.playAt); with false, it shows its list again; left out or null, it switches to the other view. The panel menu's Button Mode runs this and shows a check mark while it is on. The view is kept with the UI preferences. Turning it on while an action records, or an enabled that is not true, false or null, is an error and the view stays → {enabled}",
),
(
crate::panels::actions::PLAYBACK_OPTIONS,
"Playback Options",
+103 -10
View File
@@ -7,20 +7,40 @@ use serde_json::json;
use crate::theme::Tokens;
use crate::{VectorcraftApp, menus};
/// Engine commands the palette leaves out, each with the UI command of the same name that runs it
/// the way the panels and menus do: (engine id, UI command id). The Layers panel's Delete
/// Selection asks before deleting a layer that holds art, and the other UI commands open the
/// command's dialog or file chooser. Scripts and the control channel still run the engine ids.
const WRAPPED: &[(&str, &str)] = &[
("layer.delete", "ui.layerDelete"),
("brush.new", "ui.newBrush"),
("brush.options", "ui.brushOptions"),
("symbol.options", "ui.symbolOptions"),
("svgFilter.import", "ui.importSvgFilter"),
("text.convertListToText", "type.convertListToText"),
];
/// Everything the palette searches: (label, command id or `tool:<id>`, shortcut).
pub fn items() -> Vec<(String, String, String)> {
let mut items = vec![];
// Aliases kept for older scripts run a command that is listed already.
// Aliases kept for older scripts run a command that is listed already, and a wrapped engine
// command is listed as its UI command.
let plugin_commands = vectorcraft_engine::cmd::plugin::command_specs();
for c in vectorcraft_engine::command_specs().iter().filter(|c| !vectorcraft_engine::cmd::is_alias(c.id)).chain(plugin_commands) {
let path = c.menu.join(" › ");
let label = if path.is_empty() { c.label.to_string() } else { format!("{path} › {}", c.label) };
items.push((label, c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
let listed = |id: &str| !vectorcraft_engine::cmd::is_alias(id) && !WRAPPED.iter().any(|(engine, _)| *engine == id);
let labeled = |menu: &[&str], label: &str| {
let path = menu.join(" › ");
if path.is_empty() { label.to_string() } else { format!("{path} › {label}") }
};
for c in vectorcraft_engine::command_specs().iter().filter(|c| listed(c.id)).chain(plugin_commands) {
items.push((labeled(c.menu, c.label), c.id.to_string(), menus::shortcut_of(c.id).unwrap_or("").to_string()));
}
// `plugin.dialog` runs nothing without an id: [`build`] lists each filter instead. A script of
// the Scripts folder runs from File › Scripts, by its path.
// the Scripts folder runs from File › Scripts, by its path. A UI command that wraps an engine
// command takes that command's menu path, so a search for the path still finds it.
for c in menus::UI_COMMANDS.iter().filter(|c| c.0 != "plugin.dialog" && c.0 != crate::scripts_menu::COMMAND) {
items.push((c.1.to_string(), c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string()));
let engine = WRAPPED.iter().find(|(_, ui)| *ui == c.0).and_then(|(engine, _)| vectorcraft_engine::find_command(engine));
let label = engine.map_or_else(|| c.1.to_string(), |e| labeled(e.menu, c.1));
items.push((label, c.0.to_string(), menus::shortcut_of(c.0).unwrap_or("").to_string()));
}
for tool in vectorcraft_tools::catalog::all_tools() {
items.push((tool.label.to_string(), format!("tool:{}", tool.id), crate::shortcut_editor::tool_shortcut(tool.id).unwrap_or("").to_string()));
@@ -40,8 +60,9 @@ pub(crate) struct Entry {
shortcut: String,
/// The label in the UI language.
shown: String,
/// The English label (what agents document), the shown label and the id, lowercased, one per
/// line: a query word (which has no line break) matches within one of them.
/// The English label (what agents document), the shown label and the id (and the id of the
/// engine command a UI command wraps), lowercased, one per line: a query word (which has no
/// line break) matches within one of them.
haystack: String,
}
@@ -71,7 +92,12 @@ fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc<Vec<Entry>> {
/// item (`plugin:<id>`), whose name is not translated.
fn build(lang: crate::i18n::Lang) -> Vec<Entry> {
let entry = |label: String, shown: String, id: String, shortcut: String| {
let haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase());
let mut haystack = format!("{}\n{}\n{}", label.to_lowercase(), shown.to_lowercase(), id.to_lowercase());
// A UI command that wraps an engine command is also found by the engine command's id.
if let Some((engine, _)) = WRAPPED.iter().find(|(_, ui)| *ui == id) {
haystack.push('\n');
haystack.push_str(&engine.to_lowercase());
}
Entry { id, shortcut, shown, haystack }
};
let mut out: Vec<Entry> = items()
@@ -250,6 +276,73 @@ mod tests {
assert_eq!((st.selection.len(), st.selection.key), (2, None), "both objects stay selected, without a key");
}
/// An engine command that a UI command of the same name runs is listed once, as the UI command:
/// the Layers panel's Delete Selection, New Brush…, Brush Options…, Symbol Options…, Import SVG
/// Filter… and Convert to Text each have one result.
#[test]
fn a_wrapped_engine_command_is_listed_once_as_its_ui_command() {
let items = items();
for (engine, ui) in WRAPPED {
let spec = vectorcraft_engine::command_specs().iter().find(|c| c.id == *engine).unwrap_or_else(|| panic!("{engine} is a command"));
let wrapper = menus::UI_COMMANDS.iter().find(|c| c.0 == *ui).unwrap_or_else(|| panic!("{ui} is a UI command"));
assert_eq!(spec.label, wrapper.1, "{engine} and {ui} share a name");
assert!(items.iter().all(|(_, id, _)| id != engine), "{engine} is left out");
let listed: Vec<&str> = items.iter().filter(|(_, id, _)| id == ui).map(|(label, ..)| label.as_str()).collect();
let path: String = spec.menu.iter().map(|m| format!("{m} › ")).collect();
assert_eq!(listed, [format!("{path}{}", wrapper.1)], "{ui} is listed once, with {engine}'s menu path");
}
// A search for Delete Selection, or for its menu path, finds the Layers panel's, and not the
// engine command that deletes without asking; the path translates.
let entries = build(Lang::EN);
for (engine, ui) in WRAPPED {
assert!(entries.iter().any(|e| e.id == *ui && e.haystack.contains(&engine.to_lowercase())), "a search for {engine} finds {ui}");
}
for query in ["delete selection", "layers delete", "layer.delete"] {
let found: Vec<&str> =
entries.iter().filter(|e| query.split_whitespace().all(|w| e.haystack.contains(w))).map(|e| e.id.as_str()).collect();
assert!(found.contains(&"ui.layerDelete") && !found.contains(&"layer.delete"), "{query}: {found:?}");
}
let zh = Lang::from_code("zh-hant").unwrap();
let shown = build(zh).into_iter().find(|e| e.id == "ui.layerDelete").map(|e| e.shown).unwrap();
let want = ["Window", "Layers", "Delete Selection"].map(|s| crate::i18n::tr(zh, s)).join(" › ");
assert_eq!(shown, want);
}
/// Every result a search for Delete Selection gives asks before deleting a layer that holds art:
/// Cancel keeps it, and OK deletes it in one undo step. A script's `layer.delete` still deletes
/// at once.
#[test]
fn a_pick_of_delete_selection_asks_before_deleting_a_layer_with_art() {
let query = "delete selection";
let picks: Vec<String> =
build(Lang::EN).into_iter().filter(|e| query.split_whitespace().all(|w| e.haystack.contains(w))).map(|e| e.id).collect();
assert!(!picks.is_empty(), "the search finds Delete Selection");
let node = |v: &serde_json::Value| vectorcraft_doc::NodeId(v.as_u64().unwrap());
let exists = |app: &VectorcraftApp, v: &serde_json::Value| app.session.active().unwrap().doc.node(node(v)).is_some();
let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
let asked = |app: &VectorcraftApp| app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::confirm::KIND);
for pick in &picks {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let layer = app.run("layer.new", json!({})).unwrap()["id"].clone();
let art = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
app.run("select.none", json!({})).unwrap();
let before = undo(&app);
run_pick(&mut app, pick);
assert!(asked(&app), "{pick} asks first: {}", app.ui.status);
assert!(exists(&app, &layer) && exists(&app, &art) && undo(&app) == before, "{pick}: nothing is deleted yet");
crate::dialogs::cancel(&mut app);
assert!(exists(&app, &layer) && exists(&app, &art) && undo(&app) == before, "{pick}: Cancel keeps the layer");
run_pick(&mut app, pick);
crate::dialogs::confirm(&mut app).unwrap();
assert!(!exists(&app, &layer) && !exists(&app, &art), "{pick}: OK deletes the layer and its art");
assert_eq!(undo(&app), before + 1, "{pick}: one undo step");
app.run("edit.undo", json!({})).unwrap();
app.run("layer.delete", json!({ "ids": [layer] })).unwrap();
assert!(app.ui.dialog.is_none() && !exists(&app, &layer), "a script deletes at once");
}
}
/// #4864: a pick of an Asset Export command, which the palette runs with no params, opens the
/// Asset Export panel instead of failing on the missing params or exporting nowhere.
#[test]
+332 -7
View File
@@ -32,8 +32,9 @@ pub struct Action {
/// saved value that is not a function key loads as none.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_key")]
pub key: Option<FunctionKey>,
/// The action's Button Mode color, one of [`COLORS`], if one is assigned. The panel does not
/// show it. A saved value that is not one of them loads as none.
/// The action's Button Mode color, one of [`COLORS`], if one is assigned: the color of its
/// button while the panel is in Button Mode ([`button_colors`]). A saved value that is not one
/// of them loads as none.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "deserialize_color")]
pub color: Option<String>,
}
@@ -144,6 +145,40 @@ impl FunctionKey {
/// The Button Mode colors an action can have, as (value, label).
pub const COLORS: [(&str, &str); 5] = [("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("violet", "Violet")];
/// The fill of a Button Mode button in color `color`, one of [`COLORS`]: VectorCraft's own shades.
fn button_fill(color: &str) -> Option<egui::Color32> {
let rgb = egui::Color32::from_rgb;
Some(match color {
"red" => rgb(0xd4, 0x3f, 0x3f),
"yellow" => rgb(0xf0, 0xc8, 0x3c),
"green" => rgb(0x3f, 0xa8, 0x5a),
"blue" => rgb(0x3d, 0x7b, 0xd6),
"violet" => rgb(0x8e, 0x5a, 0xd0),
_ => return None,
})
}
/// The relative luminance of `c`, as WCAG 2 defines it.
fn luminance(c: egui::Color32) -> f32 {
let channel = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b())
}
/// The (fill, text) colors of the Button Mode button of an action with color `color`: a colored
/// fill with black or white text, whichever contrasts more with it, or the theme's ordinary button
/// for an action without a color.
fn button_colors(color: Option<&str>, t: &Tokens) -> (egui::Color32, egui::Color32) {
match color.and_then(button_fill) {
// Black text contrasts more than white above this luminance.
Some(fill) if luminance(fill) > 0.179 => (fill, egui::Color32::BLACK),
Some(fill) => (fill, egui::Color32::WHITE),
None => (t.button, t.text_strong),
}
}
/// Serde reader for [`Action::key`]: a value that is not a function key is none.
fn deserialize_key<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<FunctionKey>, D::Error> {
let v = <Value as serde::Deserialize>::deserialize(d)?;
@@ -888,6 +923,37 @@ pub fn play_action(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String>
}
}
/// The command that plays an action by its place in the panel ([`play_at`]).
pub const PLAY_AT: &str = "ui.action.playAt";
/// `ui.action.playAt {set, action}`: plays the action at index `action` of set `set` (0 is the top
/// set) at the Playback Options speed, as [`play`] does; a click on an action's button in Button
/// Mode runs this. A missing index, or one that is not a whole number or is past the last set or
/// action, is an error, and then nothing plays. → what [`play`] returns
pub fn play_at(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
let (set, at) = index("set").zip(index("action")).ok_or("no such action")?;
play(app, set, at)
}
/// The command behind the panel menu's Button Mode ([`button_mode`]).
pub const BUTTON_MODE: &str = "ui.action.buttonMode";
/// `ui.action.buttonMode {enabled?}`: with `enabled: true` the Actions panel shows every action of
/// every set as a button in its color ([`Action::color`]) that plays the action when clicked, and
/// with `false` it shows its list of sets, actions and steps again; left out or null, it switches
/// to the other view. The view is kept with the UI preferences. Turning Button Mode on while an
/// action records is an error, and so is an `enabled` that is not true, false or null; then the
/// view does not change. → {enabled}
pub fn button_mode(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let enabled = crate::menus::opt_bool(p, "enabled")?.unwrap_or(!app.ui.action_button_mode);
if enabled && !app.ui.action_button_mode && app.ui.recording.is_some() {
return Err("an action is already recording".into());
}
app.ui.action_button_mode = enabled;
Ok(json!({ "enabled": enabled }))
}
/// The command that ends a recording ([`stop`]).
pub const STOP_RECORDING: &str = "ui.action.stop";
@@ -2007,9 +2073,18 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
/// The panel's (≡) menu.
pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
// Button Mode is off while an action records, so the Stop button the list shows stays in reach.
let buttons = app.ui.action_button_mode;
if widgets::menu_item(ui, tl!("Button Mode"), buttons || app.ui.recording.is_none(), buttons)
&& let Err(e) = app.run(BUTTON_MODE, json!({}))
{
app.status(e);
}
ui.separator();
// A selected action counts only while its set is the current one, as the panel shows it: a set
// deleted since leaves the stored pair pointing at another set's action.
let selected = pstate::<Option<(usize, usize)>>(ui.ctx(), "actions-selected").filter(|&(s, _)| app.ui.action_set == Some(s));
// deleted since leaves the stored pair pointing at another set's action. Button Mode shows no
// selected action and no current set, so the items that act on them are off there.
let selected = pstate::<Option<(usize, usize)>>(ui.ctx(), "actions-selected").filter(|&(s, _)| !buttons && app.ui.action_set == Some(s));
let has = selected.and_then(|(s, a)| app.ui.action_sets.get(s).and_then(|set| set.actions.get(a))).is_some();
if widgets::menu_item(ui, tl!("New Action…"), crate::dialogs::action_new::can_open(app), false)
&& let Err(e) = crate::dialogs::action_new::open(app)
@@ -2019,11 +2094,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
if widgets::menu_item(ui, tl!("New Set…"), true, false) {
crate::dialogs::action_set_options::open_new(app);
}
let current = app.ui.action_set.filter(|&s| s < app.ui.action_sets.len());
let current = app.ui.action_set.filter(|&s| !buttons && s < app.ui.action_sets.len());
if widgets::menu_item(ui, tl!("Duplicate"), current.is_some(), false) {
duplicate_selection(app, ui.ctx());
}
if widgets::menu_item(ui, tl!("Delete"), has || !selected_sets(app).is_empty(), false) {
if widgets::menu_item(ui, tl!("Delete"), has || (!buttons && !selected_sets(app).is_empty()), false) {
delete_selection(app, ui.ctx());
}
if widgets::menu_item(ui, tl!("Set Options…"), current.is_some(), false)
@@ -2156,8 +2231,78 @@ enum InclusionChange {
Step(usize, usize, usize, bool),
}
/// Draw action sets, steps and playback controls, keeping the list above a pinned footer.
/// Button Mode ([`button_mode`]): every action of every set, in set order and then action order,
/// as a button with its name in its color ([`button_colors`]), with no set names, steps or
/// checkmarks. A click on a button, or Enter or Space on the focused one, plays the action
/// ([`PLAY_AT`]). The footer shows only while an action plays or records: Stop Playing/Recording,
/// and Play Current Selection while a playback waits at a stop.
fn show_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let mut play_req = None;
let list_height = widgets::panel_list_height(ui, 260.0, 0.0).min(260.0);
egui::ScrollArea::vertical().id_salt("actions-buttons").max_height(list_height).min_scrolled_height(list_height.min(64.0)).show(ui, |ui| {
for (si, set) in app.ui.action_sets.iter().enumerate() {
for (ai, a) in set.actions.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &a.name));
let (fill, text) = button_colors(a.color.as_deref(), &t);
let button = r.shrink2(egui::vec2(2.0, 1.0));
ui.painter().rect_filled(button, 3.0, fill);
// The pointer over a button, or the keyboard focus on it, rings it in its text color.
let (width, ring) = if resp.hovered() || resp.has_focus() { (2.0, text) } else { (1.0, t.button_border) };
ui.painter().rect_stroke(button, 3.0, egui::Stroke::new(width, ring), egui::StrokeKind::Inside);
ui.painter().with_clip_rect(button.shrink2(egui::vec2(6.0, 0.0))).text(
button.left_center() + egui::vec2(8.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.name,
egui::FontId::proportional(12.5),
text,
);
if resp.on_hover_text(&a.name).clicked() {
play_req = Some((si, ai));
}
}
}
});
if app.ui.action_sets.iter().all(|s| s.actions.is_empty()) {
dim_label(ui, tl!("No actions."));
}
let waiting = waiting_at_stop(app);
if waiting {
dim_label(ui, tl!("Press Play Current Selection in the Actions panel to go on."));
}
let recording = app.ui.recording.is_some();
if recording || app.ui.action_playing.is_some() {
widgets::bottom_bar(ui, |ui| {
if widgets::icon_button(ui, "square", tl!("Stop Playing/Recording"), false, 24.0).clicked()
&& let Err(e) = app.run(STOP_RECORDING, json!({}))
{
app.status(e);
}
if waiting
&& widgets::icon_button(ui, "dc-actions", tl!("Play Current Selection"), false, 24.0).clicked()
&& let Err(e) = app.run(CONTINUE_PLAYBACK, json!({}))
{
app.status(e);
}
if recording {
ui.label(egui::RichText::new(tl!("● Recording")).color(egui::Color32::from_rgb(0xe0, 0x30, 0x30)));
}
});
}
if let Some((si, ai)) = play_req
&& let Err(e) = app.run(PLAY_AT, json!({"set": si, "action": ai}))
{
app.status(e);
}
}
/// Draw action sets, steps and playback controls, keeping the list above a pinned footer, or in
/// Button Mode the actions as buttons ([`show_buttons`]).
pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
if app.ui.action_button_mode {
return show_buttons(app, ui);
}
let t = Tokens::get(ui.ctx());
// The selected action as (set, action). Read as the type it is stored as: egui keeps a
// value per type, so reading the bare pair never found it and no action stayed selected (#1629).
@@ -2672,6 +2817,186 @@ mod tests {
assert_eq!(names(&app), before[1..], "nothing selected after a delete: nothing more goes");
}
/// The fills of the rectangles drawn in one headless frame of the panel.
fn drawn_fills(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec<egui::Color32> {
fn collect(s: &egui::Shape, out: &mut Vec<egui::Color32>) {
match s {
egui::Shape::Rect(r) => out.push(r.fill),
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
output.textures_delta.clear();
let mut fills = vec![];
output.shapes.iter().for_each(|c| collect(&c.shape, &mut fills));
fills
}
/// The panel menu's Button Mode switches the panel to buttons and back, with a check mark while
/// it is on; `ui.action.buttonMode` does the same, and the view is kept with the UI preferences.
/// Turning it on while an action records is refused, so Stop stays in reach.
#[test]
fn button_mode_switches_the_view_and_is_kept_with_the_preferences() {
use super::super::tests_appearance::{click, frame_events};
let mut app = app_with_sets();
assert!(!app.ui.action_button_mode, "the list is the default");
let ctx = egui::Context::default();
let item = |ctx: &egui::Context, app: &mut VectorcraftApp| {
let texts = frame_events(ctx, app, vec![], menu);
texts.into_iter().find(|(s, _)| s.trim_start_matches('✓').trim() == "Button Mode").expect("Button Mode in the menu")
};
let (label, at) = item(&ctx, &mut app);
assert_eq!(label, " Button Mode", "no check mark while off");
click(&ctx, &mut app, at.center(), menu);
assert!(app.ui.action_button_mode, "the menu item turns it on");
assert_eq!(item(&ctx, &mut app).0, "✓ Button Mode", "a check mark while on");
let at = item(&ctx, &mut app).1;
click(&ctx, &mut app, at.center(), menu);
assert!(!app.ui.action_button_mode, "choosing it again returns to the list");
assert_eq!(app.run(BUTTON_MODE, json!({})).unwrap(), json!({"enabled": true}), "left out: the other view");
assert_eq!(app.run(BUTTON_MODE, json!({"enabled": true})).unwrap(), json!({"enabled": true}));
assert_eq!(app.run(BUTTON_MODE, json!({"enabled": "no"})).unwrap_err(), "`enabled` must be true or false");
assert!(app.ui.action_button_mode, "a bad value keeps the view");
let saved = serde_json::to_value(&app.ui).unwrap();
assert_eq!(saved["action_button_mode"], json!(true));
assert!(serde_json::from_value::<crate::state::UiState>(saved.clone()).unwrap().action_button_mode, "kept after a restart");
let mut older = saved;
older.as_object_mut().unwrap().remove("action_button_mode");
assert!(!serde_json::from_value::<crate::state::UiState>(older).unwrap().action_button_mode, "older preferences show the list");
assert_eq!(app.run(BUTTON_MODE, json!({"enabled": null})).unwrap(), json!({"enabled": false}));
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run(START_RECORDING, json!({})).unwrap();
assert_eq!(app.run(BUTTON_MODE, json!({})).unwrap_err(), "an action is already recording");
let at = item(&ctx, &mut app).1;
click(&ctx, &mut app, at.center(), menu);
assert!(!app.ui.action_button_mode, "the menu item is off while an action records");
assert_eq!(app.run(BUTTON_MODE, json!({"enabled": false})).unwrap(), json!({"enabled": false}), "turning it off is never refused");
app.run(STOP_RECORDING, json!({})).unwrap();
}
/// Button Mode shows every action of every set by its name, in set order and then action order,
/// without set names, step counts or steps, each in its Action Options color; an action without
/// a color is an ordinary button. The menu items for a selected action or the current set are
/// off, since the view shows neither.
#[test]
fn button_mode_shows_every_action_as_a_button_in_its_color() {
use super::super::tests_appearance::{click, frame_events, text_rect};
let mut app = app_with_sets();
app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "color": "red"})).unwrap();
app.run(ACTION_OPTIONS, json!({"set": 1, "action": 1, "color": "yellow"})).unwrap();
let ctx = egui::Context::default();
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "Nudge").center(), show);
assert_eq!(pstate::<Option<(usize, usize)>>(&ctx, "actions-selected"), Some((1, 0)), "selected in the list");
app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
let texts = frame_events(&ctx, &mut app, vec![], show);
let names: Vec<&str> = texts.iter().map(|(s, _)| s.as_str()).collect();
assert_eq!(
names,
[
"Duplicate and Offset 10 pt",
"Unite and Simplify",
"Center on Artboard",
"Outline Text",
"Rotate 90° CW",
"Reflect Horizontal",
"Nudge",
"Nudge Again"
],
"only the actions' names, in set and action order"
);
let tops: Vec<f32> = texts.iter().map(|(_, r)| r.top()).collect();
assert!(tops.windows(2).all(|w| w[0] < w[1]), "one button under the other: {tops:?}");
let t = Tokens::get(&ctx);
let fills = drawn_fills(&ctx, &mut app);
for color in ["red", "yellow"] {
let fill = button_fill(color).unwrap();
assert_eq!(fills.iter().filter(|f| **f == fill).count(), 1, "{color}: {fills:?}");
}
assert_eq!(fills.iter().filter(|f| **f == t.button).count(), 6, "the six actions without a color: {fills:?}");
for (color, _) in COLORS {
let (fill, text) = button_colors(Some(color), &t);
let (hi, lo) = (luminance(fill).max(luminance(text)), luminance(fill).min(luminance(text)));
assert!((hi + 0.05) / (lo + 0.05) >= 4.5, "{color}: its name is legible");
}
assert_eq!(button_colors(None, &t), (t.button, t.text_strong));
let menu_texts = frame_events(&ctx, &mut app, vec![], menu);
click(&ctx, &mut app, text_rect(&menu_texts, " Delete").center(), menu);
click(&ctx, &mut app, text_rect(&menu_texts, " Action Options…").center(), menu);
assert!(app.ui.dialog.is_none(), "Delete and Action Options… are off in Button Mode");
assert_eq!(set_names(&app), ["Default Actions", "Mine", "Other"]);
assert_eq!(action_names(&app, 1), ["Nudge", "Nudge Again"], "the action selected in the list stays");
}
/// A click on a button plays that action at once, as one undo step at Accelerated, without its
/// excluded steps, which stay excluded; `ui.action.playAt` refuses an index that names no action.
#[test]
fn a_click_on_a_button_plays_its_action_without_its_excluded_steps() {
use super::super::tests_appearance::{click, frame_events, text_rect};
let (mut app, x) = app_with_three_moves();
app.run(INCLUDE_STEPS, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap();
app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
let ctx = egui::Context::default();
let texts = frame_events(&ctx, &mut app, vec![], show);
let undo = undo_steps(&app);
click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show);
assert_eq!((x(&app), undo_steps(&app)), (5.0, undo + 1), "1 and 4 pt as one undo step; the 2 pt step is skipped");
assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([1]), "the exclusion stays");
for (p, want) in [
(json!({"set": 0, "action": 1}), "no such action"),
(json!({"set": 1, "action": 0}), "no such action"),
(json!({"set": "0", "action": 0}), "no such action"),
(json!({"set": 0}), "no such action"),
] {
assert_eq!(app.run(PLAY_AT, p.clone()).unwrap_err(), want, "{p}");
}
assert_eq!(x(&app), 5.0, "nothing more played");
app.run(PLAY_AT, json!({"set": 0, "action": 0})).unwrap();
assert_eq!(x(&app), 10.0);
}
/// Button Mode shows no bottom bar while nothing plays or records. While an action plays step by
/// step its Stop Playing/Recording button ends the playback, and while one waits at a stop its
/// Play Current Selection button plays on.
#[test]
fn button_mode_shows_stop_and_continue_only_while_an_action_plays() {
use super::super::tests_appearance::{click, frame_events, text_rect};
let bar = |ctx: &egui::Context, app: &mut VectorcraftApp| {
frame_events(ctx, app, vec![], show);
let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0));
bar.sort_by(|a, b| a.left().total_cmp(&b.left()));
// A button can be listed more than once in a pass.
bar.dedup();
bar
};
let (mut app, x) = app_with_three_moves();
app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap();
let ctx = egui::Context::default();
assert_eq!(bar(&ctx, &mut app), [], "no bottom bar while idle");
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show);
assert_eq!(x(&app), 1.0, "the first step played");
let [stop] = bar(&ctx, &mut app)[..] else { panic!("only Stop while it plays") };
click(&ctx, &mut app, stop.center(), show);
assert!(app.ui.action_playing.is_none(), "Stop ends the playback");
assert_eq!(x(&app), 1.0);
let (mut app, x) = app_with_a_stop(true);
app.run(BUTTON_MODE, json!({"enabled": true})).unwrap();
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show);
assert!(waiting_at_stop(&app));
assert_eq!(x(&app), 1.0, "played up to the stop");
let [_, play] = bar(&ctx, &mut app)[..] else { panic!("Stop and Play Current Selection at a stop") };
click(&ctx, &mut app, play.center(), show);
assert!(app.ui.action_playing.is_none());
assert_eq!(x(&app), 7.0, "Play Current Selection plays on");
}
/// #3712: Create New Set asks for a name (Set 1 when free), the new set is the current one, so a
/// recording goes into it, and a click on another set's name makes that one current. The sets
/// are saved with the UI preferences.
+103 -29
View File
@@ -2,6 +2,7 @@
//! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps,
//! Superscript, Subscript, Underline and Strikethrough toggles and the Language menu.
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use egui::{Key, Ui, vec2};
@@ -99,8 +100,8 @@ pub(crate) fn shared_text_flag(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc
}
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
/// caret's style at a caret), else every run of every frame of the selected stories
/// ([`selected_stories`]). `None` (a blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
shared_styles(app, |_, _, style, _| Some(f(style)))
}
@@ -162,12 +163,8 @@ fn shared_styles<T: PartialEq>(
None => each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))),
}
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
}
});
for t in selected_story_texts(app) {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
}
}
acc.value().flatten()
@@ -243,9 +240,12 @@ pub(crate) fn format(app: &mut VectorcraftApp, p: Value) {
/// [`format`] that returns the command's result: `text.setRangeStyle` on the range the Type tool
/// has selected (the whole text when nothing is selected) while it edits text, else
/// `text.setFormat` on the selected text objects.
/// `text.setFormat` on the selected stories ([`selected_stories`]).
pub(crate) fn run_format(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
let Some((id, a, b)) = text_editing(app) else { return app.run("text.setFormat", p) };
let Some((id, a, b)) = text_editing(app) else {
target_selected_stories(app, &mut p);
return app.run("text.setFormat", p);
};
if let Some(first) = text_story(app).and_then(|story| story.first().copied()) {
return story_style(app, first, p);
}
@@ -283,9 +283,11 @@ pub(crate) fn shared_warichu(app: &VectorcraftApp) -> SharedWarichu {
}
/// Apply character attributes: to the selected range while the Type tool edits text (the whole
/// object when nothing is selected), else to the selected text objects.
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
/// object when nothing is selected), else to every frame of the selected stories
/// ([`selected_stories`]).
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
if text_editing(app).is_none() {
target_selected_stories(app, &mut p);
app.run(cmd, p).ok();
return;
}
@@ -546,6 +548,55 @@ pub(crate) fn text_story(app: &VectorcraftApp) -> Option<Vec<NodeId>> {
(ids.len() >= 2).then_some(ids)
}
/// The type objects the panels act on with the Selection tool: the selected type objects and those
/// in selected groups, each with every frame of its threaded story (locked and hidden frames
/// included), each once, the frames of a story in thread order.
pub(crate) fn selected_stories(app: &VectorcraftApp) -> Vec<NodeId> {
let Some(st) = app.session.active() else { return Vec::new() };
let mut frames = Vec::new();
let mut taken = HashSet::new();
for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
selected.walk(&mut |child| {
if matches!(child.kind, NodeKind::Text(_)) && !taken.contains(&child.id) {
for id in st.doc.story_of(child.id) {
if taken.insert(id) {
frames.push(id);
}
}
}
});
}
frames
}
/// The type objects of [`selected_stories`].
pub(crate) fn selected_story_texts(app: &VectorcraftApp) -> Vec<&vectorcraft_doc::TextObject> {
let Some(st) = app.session.active() else { return Vec::new() };
selected_stories(app)
.into_iter()
.filter_map(|id| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
_ => None,
})
.collect()
}
/// Give the command parameters `p` the `ids` of [`selected_stories`], so a command run with the
/// Selection tool sets every frame of each selected story. `p` stays as it is when it names its
/// targets, with no type selected, and while an object of a symbol instance's art is selected,
/// which the commands refuse.
pub(crate) fn target_selected_stories(app: &VectorcraftApp, p: &mut Value) {
if p.get("ids").is_some() || p.get("id").is_some() || app.session.active().is_none_or(|st| st.selection.symbol_part.is_some()) {
return;
}
let ids = selected_stories(app);
if let Some(params) = p.as_object_mut()
&& !ids.is_empty()
{
params.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
}
}
/// `text.setRangeStyle` with `p` on all the text of every frame of the story that `first` is a
/// frame of, as one undo step.
fn story_style(app: &mut VectorcraftApp, first: NodeId, mut p: Value) -> Result<Value, String> {
@@ -910,7 +961,8 @@ fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) {
}
/// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits,
/// else the selected type (groups' included). `None` where they differ, or with no type.
/// else every frame of the selected stories ([`selected_stories`]). `None` where they differ, or
/// with no type.
fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
let st = app.session.active()?;
let mut acc = Shared::None;
@@ -921,12 +973,8 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
}
}
None => {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
acc.add(t.anti_alias);
}
});
for t in selected_story_texts(app) {
acc.add(t.anti_alias);
}
}
}
@@ -934,12 +982,14 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
}
/// Set the anti-aliasing method `m` of the frame the Type tool edits, whatever part of its text
/// is selected, else the selected type objects (`text.setFormat {antiAlias}`).
/// is selected, else every frame of the selected stories (`text.setFormat {antiAlias}`).
fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) {
let mut p = json!({"antiAlias": m.id()});
if let Some((id, _, _)) = text_editing(app) {
end_typing(app);
p["ids"] = json!([id.0]);
} else {
target_selected_stories(app, &mut p);
}
app.run("text.setFormat", p).ok();
}
@@ -1160,25 +1210,49 @@ fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
}
super::live_run(app, "Character", "text.setRangeStyle", q, phase);
}
None => super::live_run(app, "Character", "text.setFormat", json!({ "fontAxes": axes }), phase),
None => {
let mut p = json!({ "fontAxes": axes });
target_selected_stories(app, &mut p);
super::live_run(app, "Character", "text.setFormat", p, phase);
}
}
}
/// The Control bar's type controls while type is selected or edited: the Character link (the
/// Character panel in a popover), the font family and style menus and the Font Size combo.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((s, _)) = text_style(app) else { return };
let link = ui
.link(egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline())
.on_hover_text(tl!("Character options"));
/// Character panel in a popover), the font family and style menus and the Font Size combo. They
/// show whole, with `reserve` left after them, or not at all: it returns false when type is selected
/// or edited and the row has no room for them.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool {
let Some((s, _)) = text_style(app) else { return true };
if ui.available_width() < control_bar_width(ui) + reserve {
return false;
}
let link = ui.link(control_bar_link_text(ui)).on_hover_text(tl!("Character options"));
widgets::popover(&link, link.clicked(), |ui| {
ui.set_width(260.0);
show_owned(app, ui, crate::font_menu::WheelOwner::ControlPopover(link.id));
});
font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0));
size_field(app, ui, "cb-font-size", 96.0);
let [family, style, size] = CONTROL_BAR_WIDTHS;
font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (family, style));
size_field(app, ui, "cb-font-size", size);
ui.add_space(4.0);
crate::chrome::group_separator(ui);
true
}
/// The Control bar's widths of the font family menu, the font style dropdown and the Font Size combo.
const CONTROL_BAR_WIDTHS: [f32; 3] = [150.0, 96.0, 96.0];
/// The text of the Control bar's Character link, as [`control_bar`] draws it.
fn control_bar_link_text(ui: &Ui) -> egui::RichText {
egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline()
}
/// The room [`control_bar`]'s link, menus and Font Size combo take in the Control bar's row, with
/// the spacing after each.
fn control_bar_width(ui: &Ui) -> f32 {
let spacing = ui.spacing().item_spacing.x;
widgets::text_width(ui, control_bar_link_text(ui)) + CONTROL_BAR_WIDTHS.iter().sum::<f32>() + 4.0 * spacing
}
/// The Contextual Task Bar's widths of the font family menu, the font style dropdown and the
@@ -1741,3 +1741,141 @@ fn story_changes_take_in_locked_and_hidden_frames() {
assert_eq!(story_sizes(&app, id).iter().filter(|s| (**s - 9.0).abs() > 1e-9).count(), 0, "frame {id} takes the size");
}
}
/// [`three_frame_story`] with its second frame locked and its third hidden, a type object of its own
/// below it, and the first frame selected with the Selection tool → the app, the frames and the
/// other type object.
fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) {
let (mut app, a, b, c) = three_frame_story(60);
let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(text_editing(&app), None);
(app, [a, b, c], other)
}
fn undo_steps(app: &VectorcraftApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
/// With the Selection tool, a selected frame of a threaded story stands for the whole story: the
/// panel's changes reach its locked and hidden frames, each in one undo step, and leave other type
/// alone.
#[test]
fn selection_tool_changes_reach_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
assert_eq!(selected_stories(&app), frames.map(NodeId).to_vec());
let steps = undo_steps(&app);
style(&mut app, json!({"size": 9}));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
assert!(story_sizes(&app, id).iter().all(|s| (*s - 9.0).abs() < 1e-9), "frame {id} takes the size");
}
assert!(story_sizes(&app, other).iter().all(|s| (*s - 12.0).abs() < 1e-9), "other type keeps its size");
app.run("edit.undo", json!({})).unwrap();
for id in frames {
assert!(story_sizes(&app, id).iter().all(|s| (*s - 12.0).abs() < 1e-9), "undo gives frame {id} its size back");
}
format(&mut app, json!({"underline": true}));
for id in frames {
assert!(story_frame(&app, id).runs.iter().all(|r| r.style.underline), "frame {id} is underlined");
}
assert!(story_frame(&app, other).runs.iter().all(|r| !r.style.underline));
// Tate-chu-yoko Settings and Warichu Settings set their values through `run_format`.
run_format(&mut app, json!({"tcyUpDown": 2})).unwrap();
for id in frames {
assert!(story_frame(&app, id).runs.iter().all(|r| (r.style.tcy_up_down - 2.0).abs() < 1e-9), "frame {id} takes Up/Down");
}
assert!(story_frame(&app, other).runs.iter().all(|r| r.style.tcy_up_down.abs() < 1e-9));
}
/// With the Selection tool the anti-aliasing method goes to every frame of the selected story.
#[test]
fn selection_tool_anti_aliasing_reaches_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
set_anti_alias(&mut app, TypeAntiAlias::Strong);
for id in frames {
assert_eq!(story_frame(&app, id).anti_alias, TypeAntiAlias::Strong, "frame {id}");
}
assert_eq!(story_frame(&app, other).anti_alias, TypeAntiAlias::Sharp);
assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong));
}
/// With the Selection tool a Variable Font slider sets the axis on every frame of the selected
/// story, in one undo step.
#[test]
fn selection_tool_font_axes_reach_every_frame_of_the_story() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let (mut app, frames, _) = story_selected_in_its_first_frame();
app.run("text.setStyle", json!({"ids": frames, "font": TWO_AXIS_FAMILY})).unwrap();
app.run("select.set", json!({"ids": [frames[0]]})).unwrap();
let steps = undo_steps(&app);
axes_cmd(&mut app, json!({"wght": 700}), widgets::Live::Released);
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
let t = story_frame(&app, id);
assert!(t.runs.iter().all(|r| r.style.font_axes.get("wght") == Some(&700.0)), "frame {id}: {:?}", t.runs[0].style.font_axes);
}
}
/// With the Selection tool a font family picked in the font menu, and its preview, go to every
/// frame of the selected story, in one undo step.
#[test]
fn selection_tool_font_family_reaches_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
let family = |app: &VectorcraftApp, id: u64| story_frame(app, id).runs.iter().map(|r| r.style.font_family.clone()).collect::<Vec<_>>();
let before = family(&app, other);
let ctx = egui::Context::default();
crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Preview("Source Serif 4".into(), None));
for id in frames {
assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "the preview shows on frame {id}");
}
let steps = undo_steps(&app);
crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Chosen("Source Serif 4".into(), None));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "frame {id} takes the family");
}
assert_eq!(family(&app, other), before, "other type keeps its family");
}
/// The fields show a value only when every frame of the selected story has it: a hidden frame that
/// differs leaves them blank.
#[test]
fn selection_tool_fields_read_every_frame_of_the_story() {
let (mut app, [a, b, c], _) = story_selected_in_its_first_frame();
assert_eq!(shared(&app, |s| s.size), Some(12.0));
assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Sharp));
app.run("text.setStyle", json!({"ids": [c], "size": 20})).unwrap();
app.run("text.setFormat", json!({"ids": [b], "antiAlias": TypeAntiAlias::Crisp.id()})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(shared(&app, |s| s.size), None, "the hidden frame's size differs");
assert_eq!(shared_anti_alias(&app), None, "the locked frame's anti-aliasing differs");
}
/// Two frames of one story, or a frame in a selected group, count once; an unthreaded type
/// object is a story of its own.
#[test]
fn selected_stories_list_each_frame_once() {
let (mut app, [a, b, c], other) = story_selected_in_its_first_frame();
app.run("object.unlockAll", json!({})).unwrap();
app.run("select.set", json!({"ids": [b, a]})).unwrap();
assert_eq!(selected_stories(&app), [a, b, c].map(NodeId).to_vec(), "thread order, each once");
app.run("select.set", json!({"ids": [other, a]})).unwrap();
let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [group]})).unwrap();
let mut listed = selected_stories(&app);
listed.sort_by_key(|id| id.0);
let mut expected = [a, b, c, other].map(NodeId).to_vec();
expected.sort_by_key(|id| id.0);
assert_eq!(listed, expected);
app.run("select.set", json!({"ids": []})).unwrap();
assert!(selected_stories(&app).is_empty());
}
+180 -60
View File
@@ -268,6 +268,66 @@ fn disclosure(ui: &Ui, view: &View, n: &Node, (r, x): (egui::Rect, f32), open: b
}
}
/// The row's name label or inline rename field. Returns (name_end, name_rect).
fn row_name(ui: &mut Ui, view: &View, item: &Row, r: egui::Rect, x: f32, out: &mut Out) -> (f32, egui::Rect) {
let n = &*item.node;
let doc = view.doc;
let t = &view.t;
let xml_id = view.identify_xml_id;
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.identify_label(xml_id) };
let font = egui::FontId::proportional(13.0);
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id()));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0));
let name_end = match renaming {
Some((rid, mut buf)) if rid == n.id.0 => {
let field = rename_id().with("field");
let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
let te = child
.add(egui::TextEdit::singleline(&mut buf).id(field).align(egui::Align2::LEFT_CENTER).desired_width(name_rect.width()).font(font));
// Focus the field as it opens. Asking every frame took the focus back from Enter,
// Escape or a click elsewhere, so the rename never ended.
if !te.has_focus() && !te.lost_focus() {
te.request_focus();
}
if te.lost_focus() {
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id()));
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf})));
}
} else {
// Shift+Enter renames it and keeps the field open.
if kept && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()})));
}
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf)));
}
name_rect.right()
}
_ => {
let painter = ui.painter().with_clip_rect(name_rect);
// Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
// XML IDs stay as ids (not translated).
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
tl!("<Opacity Mask>").to_string()
} else if xml_id {
name
} else {
painted_name(n, &n.display_name(), crate::i18n::current())
};
let text = paint_name(&painter, egui::pos2(x, r.center().y), &shown, font, if item.dim { t.text_dim } else { t.text }, prints_italic(n));
// A clipping path's name is underlined, a masked object's with a dashed line.
if item.clip_path {
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
} else if n.extras().mask.is_some() {
painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0));
}
text.right()
}
};
(name_end, name_rect)
}
/// The widgets of row `item`.
pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
if let Some(i) = item.guide {
@@ -277,8 +337,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
return spine_row(ui, view, item, out);
}
let n = &*item.node;
let (depth, clip_path, has_children, open, color) = (item.depth, item.clip_path, item.opens, item.open, item.colour);
let doc = view.doc;
let (depth, has_children, open, color) = (item.depth, item.opens, item.open, item.colour);
let t = &view.t;
let h = view.h;
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag());
@@ -309,60 +368,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
th
});
// Name (Preferences › Units › Identify Objects By: Object Name or XML ID).
let xml_id = view.identify_xml_id;
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.identify_label(xml_id) };
let font = egui::FontId::proportional(13.0);
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id()));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0));
let name_end;
match renaming {
Some((rid, mut buf)) if rid == n.id.0 => {
let field = rename_id().with("field");
let kept = crate::widgets::own_field_shift_enter(ui, field, &mut buf, &name);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect));
let te = child.add(
egui::TextEdit::singleline(&mut buf).id(field).align(egui::Align2::LEFT_CENTER).desired_width(name_rect.width()).font(font.clone()),
);
// Focus the field as it opens. Asking every frame took the focus back from Enter,
// Escape or a click elsewhere, so the rename never ended.
if !te.has_focus() && !te.lost_focus() {
te.request_focus();
}
if te.lost_focus() {
ui.data_mut(|d| d.remove::<(u64, String)>(rename_id()));
if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf})));
}
} else {
// Shift+Enter renames it and keeps the field open.
if kept && buf != name {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "name": buf.clone()})));
}
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, buf)));
}
name_end = name_rect.right();
}
_ => {
let painter = ui.painter().with_clip_rect(name_rect);
// Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
// XML IDs stay as ids (not translated).
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
tl!("<Opacity Mask>").to_string()
} else if xml_id {
name.clone()
} else {
painted_name(n, &n.display_name(), crate::i18n::current())
};
let text = paint_name(&painter, egui::pos2(x, r.center().y), &shown, font, if item.dim { t.text_dim } else { t.text }, prints_italic(n));
name_end = text.right();
// A clipping path's name is underlined, a masked object's with a dashed line.
if clip_path {
painter.line_segment([text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text));
} else if n.extras().mask.is_some() {
painter.extend(egui::Shape::dashed_line(&[text.left_bottom(), text.right_bottom()], Stroke::new(1.0, t.text), 3.0, 2.0));
}
}
}
let (name_end, name_rect) = row_name(ui, view, item, r, x, out);
// Target circle and selection square.
let col_x = r.right() - 43.5;
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
@@ -404,12 +410,18 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]})));
}
}
// Row menu: Collect for Export on this row (or every highlighted row when this is one of them).
// Row menu: context actions on this row (or every highlighted row when this is one of them).
let collect_ids: Vec<u64> = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] };
// Right-clicking an unselected row selects it; a highlighted row keeps multiselection.
if resp.secondary_clicked() && !view.rows.contains(&n.id) {
out.actions.push(("layer.setCurrent".into(), json!({"id": n.id.0})));
out.rows_after_click = Some(vec![n.id]);
}
if resp.secondary_clicked() {
crate::panels::set_row_keys(ui.ctx(), ROW_KEYS, true);
}
resp.context_menu(|ui| {
collect_for_export_menu(ui, true, |multiple| {
out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple })));
});
row_context_menu(ui, view, n, &collect_ids, out);
});
// Dragging rows: the highlighted rows when this is one of them, else this row. Only the primary
// button drags; the secondary button opens the row menu, and a drag with it moves nothing.
@@ -428,6 +440,114 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
drop_target(ui, view, n, r, x - 16.0, open, &resp, out);
}
/// The right-click context menu of row `n`.
fn row_context_menu(ui: &mut Ui, view: &View, n: &Node, collect_ids: &[u64], out: &mut Out) {
let is_multi = collect_ids.len() > 1;
// "Layer Options…" / "Options for Selection…" / "Options…"
let options_label = if is_multi {
"Options for Selection…"
} else if n.is_layer() {
"Layer Options…"
} else {
"Options…"
};
if widgets::menu_item(ui, options_label, true, false) {
out.actions.push(("ui.layerOptions".into(), json!({ "ids": collect_ids })));
ui.close();
}
// "Duplicate "<name>"" or "Duplicate Selection"
let display = {
let raw = painted_name(n, &n.display_name(), crate::i18n::current());
if raw.chars().count() > 20 {
let truncated: String = raw.chars().take(20).collect();
format!("{truncated}…")
} else {
raw
}
};
let dup_label = if is_multi { tl!("Duplicate Selection").to_string() } else { format!("{} \"{display}\"", tl!("Duplicate")) };
if widgets::menu_item_name(ui, &dup_label, true, false) {
out.actions.push(("layer.duplicate".into(), json!({ "ids": collect_ids })));
ui.close();
}
// "Delete "<name>"" or "Delete Selection"
let del_label = if is_multi { tl!("Delete Selection").to_string() } else { format!("{} \"{display}\"", tl!("Delete")) };
let del_enabled = {
let listed: std::collections::HashSet<NodeId> = collect_ids.iter().map(|&id| NodeId(id)).collect();
!view.doc.layers.iter().all(|l| listed.contains(&l.id))
};
if widgets::menu_item_name(ui, &del_label, del_enabled, false) {
out.actions.push(("layer.delete".into(), json!({ "ids": collect_ids })));
ui.close();
}
ui.separator();
// Lock Others / Unlock All Layers
{
let (lock_cmd, lock_params, lock_enabled) = multi_others_action(view.doc, collect_ids, false);
let lock_label = if lock_cmd == "layer.lockOthers" { "Lock Others" } else { "Unlock All Layers" };
if widgets::menu_item(ui, lock_label, lock_enabled, false) {
out.actions.push((lock_cmd, lock_params));
ui.close();
}
}
// Hide Others / Show All Layers
{
let (hide_cmd, hide_params, hide_enabled) = multi_others_action(view.doc, collect_ids, true);
let hide_label = if hide_cmd == "layer.hideOthers" { "Hide Others" } else { "Show All Layers" };
if widgets::menu_item(ui, hide_label, hide_enabled, false) {
out.actions.push((hide_cmd, hide_params));
ui.close();
}
}
ui.separator();
// Locate Object
if widgets::menu_item(ui, "Locate Object", true, false) {
out.actions.push(("layer.locate".into(), json!({ "id": n.id.0 })));
ui.close();
}
ui.separator();
// Collect for Export
let ids: Vec<u64> = collect_ids.to_vec();
collect_for_export_menu(ui, true, move |multiple| {
out.actions.push(("assets.add".into(), json!({ "ids": ids.clone(), "multiple": multiple })));
});
}
/// Context menu Others action for `ids`: Lock Others / Unlock All Layers or Hide Others / Show All Layers.
/// When any other layer is unlocked (visible), returns `lockOthers` (`hideOthers`) preserving all layers holding
/// `ids`. When all other layers are locked (hidden) already, returns `unlockAll` (`showAll`).
pub(crate) fn multi_others_action(doc: &Document, ids: &[u64], eye: bool) -> (String, Value, bool) {
let own_layers: std::collections::HashSet<NodeId> = ids.iter().filter_map(|&id| doc.layer_of(NodeId(id))).collect();
let has_other_active = if eye {
doc.layers.iter().any(|l| !own_layers.contains(&l.id) && l.visible)
} else {
doc.layers.iter().any(|l| !own_layers.contains(&l.id) && !l.locked)
};
let cmd = match (eye, has_other_active) {
(true, true) => "layer.hideOthers",
(true, false) => "layer.showAll",
(false, true) => "layer.lockOthers",
(false, false) => "layer.unlockAll",
};
let enabled = match (eye, has_other_active) {
(true, true) | (false, true) => true,
(true, false) => any_layer_state(doc, |n| !n.visible),
(false, false) => any_layer_state(doc, |n| n.locked),
};
let params = if cmd.ends_with("Others") { json!({ "ids": ids }) } else { json!({}) };
(cmd.into(), params, enabled)
}
fn any_layer_state(doc: &Document, pred: impl Fn(&Node) -> bool) -> bool {
let mut found = false;
doc.walk(|n| {
if !found && n.is_layer() && pred(n) {
found = true;
}
});
found
}
/// Alt-clicking row `n`'s eye (`eye`) or lock: Hide Others or Lock Others, every top-level layer
/// but the one holding the row; when they all are hidden (locked) already, Show All Layers (Unlock
/// All Layers). Each is one undo step.
+192
View File
@@ -2440,3 +2440,195 @@ fn guide_rows_list_by_index_whatever_the_node_order() {
let indexes: Vec<usize> = rows.iter().filter_map(|r| r.guide).collect();
assert_eq!(indexes, [2, 1, 0]);
}
/// Right-clicking a row that is not highlighted selects it.
#[test]
fn context_menu_right_click_selects_unselected_row() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.highlight", &json!({"ids": [], "mode": "set"})).unwrap();
let ctx = egui::Context::default();
// Rows top-first: Layer 2, Layer 1. Neither is highlighted.
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 2);
assert!(rows_of(&app).is_empty());
// Secondary-click Layer 1's row (bottom row).
let pos = circles[1] - vec2(100.0, 0.0); // name area
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], false);
frame(&mut app, &ctx, vec![button_by(pos, true, egui::PointerButton::Secondary)], false);
frame(&mut app, &ctx, vec![button_by(pos, false, egui::PointerButton::Secondary)], false);
// The row is now highlighted.
let highlighted = rows_of(&app);
let layer1 = app.session.active().unwrap().doc.layers[0].id;
assert!(highlighted.contains(&layer1), "right-click selected the unselected row");
assert!(crate::panels::has_row_keys(&ctx, super::ROW_KEYS), "secondary click claims row keys");
}
/// Right-clicking a row that IS highlighted preserves the multiselection.
#[test]
fn context_menu_right_click_preserves_multiselection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
let ids: Vec<u64> = doc.layers.iter().map(|l| l.id.0).collect();
// Highlight all 3 layers.
app.session.execute("layer.highlight", &json!({"ids": ids, "mode": "set"})).unwrap();
let ctx = egui::Context::default();
let (circles, _) = frame(&mut app, &ctx, vec![], false);
assert_eq!(circles.len(), 3);
// Secondary-click the middle row.
let pos = circles[1] - vec2(100.0, 0.0);
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], false);
frame(&mut app, &ctx, vec![button_by(pos, true, egui::PointerButton::Secondary)], false);
frame(&mut app, &ctx, vec![button_by(pos, false, egui::PointerButton::Secondary)], false);
// All 3 must still be highlighted.
assert_eq!(rows_of(&app).len(), 3, "multiselection preserved after right-click on a highlighted row");
}
/// layer.delete removes the specified layer.
#[test]
fn context_menu_delete_dispatches_layer_delete() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let layer2_id = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
let before = app.session.active().unwrap().doc.layers.len();
// Dispatch layer.delete with explicit ids (what the context menu item does).
app.session.execute("layer.delete", &json!({"ids": [layer2_id]})).unwrap();
let after = app.session.active().unwrap().doc.layers.len();
assert_eq!(after, before - 1, "layer.delete removes the layer");
}
/// layer.duplicate adds a copy.
#[test]
fn context_menu_duplicate_dispatches_layer_duplicate() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let layer_id = app.session.active().unwrap().doc.layers[0].id.0;
let before_layers = app.session.active().unwrap().doc.layers.len();
let before_undo = app.session.active().unwrap().history.undo.len();
app.session.execute("layer.duplicate", &json!({"ids": [layer_id]})).unwrap();
let after = &app.session.active().unwrap();
assert_eq!(after.doc.layers.len(), before_layers + 1, "layer.duplicate adds a layer");
assert_eq!(after.history.undo.len(), before_undo + 1, "one undo step");
}
/// layer.locate locates a specified object id without requiring a prior canvas selection.
#[test]
fn context_menu_locate_object_locates_by_id() {
let (mut app, _, [a, _]) = two_rects();
app.session.execute("select.none", &json!({})).unwrap();
assert!(app.session.active().unwrap().selection.objects.is_empty());
// Context menu passes the node's id directly to layer.locate.
let result = app.session.execute("layer.locate", &json!({"id": a.0}));
assert!(result.is_ok(), "layer.locate with id succeeds without selection: {:?}", result.err());
assert_eq!(app.session.active().unwrap().highlighted_rows(), vec![a]);
}
/// In a 1-layer document, the Delete action in the context menu is disabled when targeting that layer.
#[test]
fn context_menu_delete_disabled_when_every_layer_targeted() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
let layer_id = doc.layers[0].id;
// Targeting every layer disables delete.
let listed: std::collections::HashSet<NodeId> = [layer_id].into_iter().collect();
let del_enabled = !doc.layers.iter().all(|l| listed.contains(&l.id));
assert!(!del_enabled, "Delete must be disabled when every layer is targeted");
// Add a second layer: delete is now enabled for one layer.
app.session.execute("layer.new", &json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 2);
let del_enabled = !doc.layers.iter().all(|l| listed.contains(&l.id));
assert!(del_enabled, "Delete is enabled when at least one layer remains");
}
/// Others actions (Hide Others / Lock Others) retain all multiselected layers.
#[test]
fn context_menu_others_actions_preserve_multiselected_layers() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
app.session.execute("layer.new", &json!({})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 3);
let l1 = doc.layers[0].id.0;
let l2 = doc.layers[1].id.0;
// Hide Others with multiselection [l1, l2] hides only l3.
let (hide_cmd, hide_params, hide_enabled) = super::rows::multi_others_action(doc, &[l1, l2], true);
assert_eq!(hide_cmd, "layer.hideOthers");
assert!(hide_enabled);
assert_eq!(hide_params["ids"], json!([l1, l2]));
// Lock Others with multiselection [l1, l2] locks only l3.
let (lock_cmd, lock_params, lock_enabled) = super::rows::multi_others_action(doc, &[l1, l2], false);
assert_eq!(lock_cmd, "layer.lockOthers");
assert!(lock_enabled);
assert_eq!(lock_params["ids"], json!([l1, l2]));
}
/// Context menu Others action resolves an object row to its holding top-level layer.
#[test]
fn context_menu_others_actions_object_row_resolves_to_holding_layer() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
// Layer 1 (current) holding a rectangle.
let rect = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let rect_id = rect["id"].as_u64().unwrap();
// Create Layer 2 and hide it.
let l2 = app.session.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
app.session.execute("layer.setProps", &json!({"ids": [l2], "visible": false})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 2);
// Right-clicking the object in Layer 1: Layer 1 is visible, Layer 2 is hidden.
// There are no other visible layers, so the action should be Show All Layers, enabled.
let (hide_cmd, _hide_params, hide_enabled) = super::rows::multi_others_action(doc, &[rect_id], true);
assert_eq!(hide_cmd, "layer.showAll");
assert!(hide_enabled, "Show All Layers is enabled because Layer 2 is hidden");
// Now make Layer 2 visible, but lock it.
app.session.execute("layer.setProps", &json!({"ids": [l2], "visible": true, "locked": true})).unwrap();
let doc = &app.session.active().unwrap().doc;
// Right-clicking the object in Layer 1: Layer 1 is unlocked, Layer 2 is locked.
// There are no other unlocked layers, so the action should be Unlock All Layers, enabled.
let (lock_cmd, _lock_params, lock_enabled) = super::rows::multi_others_action(doc, &[rect_id], false);
assert_eq!(lock_cmd, "layer.unlockAll");
assert!(lock_enabled, "Unlock All Layers is enabled because Layer 2 is locked");
}
/// Context menu Show/Unlock All Layers are enabled when sublayers are hidden or locked.
#[test]
fn context_menu_others_actions_sublayer_state_enables_all_commands() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let doc = &app.session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
let l1 = doc.layers[0].id.0;
// Create a sublayer in Layer 1.
let sub = app.session.execute("layer.newSublayer", &json!({"parent": l1})).unwrap()["id"].as_u64().unwrap();
// Case 1: Sublayer is hidden while top layer is visible.
app.session.execute("layer.setProps", &json!({"ids": [sub], "visible": false})).unwrap();
let doc = &app.session.active().unwrap().doc;
let (hide_cmd, _, hide_enabled) = super::rows::multi_others_action(doc, &[l1], true);
assert_eq!(hide_cmd, "layer.showAll");
assert!(hide_enabled, "hidden sublayer enables Show All Layers");
// Case 2: Sublayer is visible, but locked while top layer is unlocked.
app.session.execute("layer.setProps", &json!({"ids": [sub], "visible": true, "locked": true})).unwrap();
let doc = &app.session.active().unwrap().doc;
let (lock_cmd, _, lock_enabled) = super::rows::multi_others_action(doc, &[l1], false);
assert_eq!(lock_cmd, "layer.unlockAll");
assert!(lock_enabled, "locked sublayer enables Unlock All Layers");
}
+16 -3
View File
@@ -9,7 +9,8 @@
//! `learn.skills` shows the list of skills, `learn.skill` opens one skill, `learn.show` opens one
//! tutorial, `learn.back` goes up one level, `learn.locateTool` shows where a tool is,
//! `learn.openSample`, `learn.finish` and `learn.next` run the three practice controls, and
//! `window.learn` shows or hides the panel.
//! `window.learn` shows or hides the panel. The Home screen's Learn section shows the same card
//! ([`guide_card`]) and the tutorials, which open the panel.
pub mod catalog;
pub mod locate;
@@ -78,8 +79,16 @@ fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, tl!("Get started"));
ui.add_space(4.0);
guide_card(app, ui, ui.scope_id().with("quick-skill-guide"), t.panel_darker);
}
/// The Quick skill guide card, filled with `fill`, as one button with the id `id`: a click, or Enter
/// or Space while it has the keyboard focus, shows the panel's list of skills (`learn.skills`). The
/// For you tab and the Home screen's Learn section show it.
pub(crate) fn guide_card(app: &mut VectorcraftApp, ui: &mut Ui, id: egui::Id, fill: egui::Color32) {
let t = Tokens::get(ui.ctx());
let card = egui::Frame::NONE
.fill(t.panel_darker)
.fill(fill)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(egui::CornerRadius::same(t.radius))
.inner_margin(egui::Margin::same(10))
@@ -96,8 +105,12 @@ fn for_you(app: &mut VectorcraftApp, ui: &mut Ui) {
.color(t.text_dim),
));
});
let resp = ui.interact(card.response.rect, ui.scope_id().with("quick-skill-guide"), Sense::click());
let resp = ui.interact(card.response.rect, id, Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Quick skill guide")));
if resp.has_focus() {
ui.painter().rect_stroke(resp.rect, egui::CornerRadius::same(t.radius), Stroke::new(1.0, t.selection), egui::StrokeKind::Inside);
}
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked()
&& let Err(e) = app.run("learn.skills", json!({}))
{
+24 -37
View File
@@ -28,8 +28,9 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [
(Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"),
];
/// Paragraph attributes apply to the selected text objects' paragraphs or, while the Type tool
/// edits text, to the paragraphs its selection (or caret) touches (ending its typing session).
/// Paragraph attributes apply to the paragraphs of every frame of the selected stories or, while
/// the Type tool edits text, to the paragraphs its selection (or caret) touches (ending its typing
/// session).
fn format(app: &mut VectorcraftApp, p: Value) {
para_cmd(app, "text.setFormat", p);
}
@@ -75,8 +76,16 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
run_on_paragraphs(app, cmd, p).ok();
}
/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result.
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result<Value, String> {
/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. With
/// the Selection tool these are the paragraphs of every frame of the selected stories
/// ([`super::character::selected_stories`]).
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Result<Value, String> {
run_on(app, cmd, p, true)
}
/// [`run_on_paragraphs`], with the Selection tool on the selected type objects alone unless
/// `stories`.
fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> Result<Value, String> {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
match super::character::text_story(app) {
@@ -88,6 +97,8 @@ pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Valu
p["end"] = json!(b);
}
}
} else if stories {
super::character::target_selected_stories(app, &mut p);
}
app.run(cmd, p)
}
@@ -208,14 +219,10 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) {
}
return;
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
node.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
for i in 0..t.paragraph_count() {
f(t.para_at(i));
}
}
});
for t in super::character::selected_story_texts(app) {
for i in 0..t.paragraph_count() {
f(t.para_at(i));
}
}
}
@@ -328,18 +335,8 @@ fn selected_paragraph_direction(app: &VectorcraftApp, fallback: &ParaStyle) -> O
{
return if collect_text_direction(text, Some((a, b)), &mut shared) { shared.or(default) } else { None };
}
fn collect(node: &vectorcraft_doc::Node, shared: &mut Option<bool>) -> bool {
if let NodeKind::Text(text) = &node.kind {
return collect_text_direction(text, None, shared);
}
node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared)))
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
if !collect(node, &mut shared) {
return None;
}
}
shared.or(default)
let texts = super::character::selected_story_texts(app);
if texts.into_iter().all(|t| collect_text_direction(t, None, &mut shared)) { shared.or(default) } else { None }
}
/// A hyphenation option shared by every paragraph in scope, or `None` when they differ.
@@ -365,18 +362,8 @@ fn selected_alignment(app: &VectorcraftApp, fallback: &ParaStyle) -> Option<Just
{
return if collect_text_alignment(text, Some((a, b)), &mut shared) { shared.or(default) } else { None };
}
fn collect(node: &vectorcraft_doc::Node, shared: &mut Option<Justify>) -> bool {
if let NodeKind::Text(text) = &node.kind {
return collect_text_alignment(text, None, shared);
}
node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared)))
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
if !collect(node, &mut shared) {
return None;
}
}
shared.or(default)
let texts = super::character::selected_story_texts(app);
if texts.into_iter().all(|t| collect_text_alignment(t, None, &mut shared)) { shared.or(default) } else { None }
}
/// The seven alignment buttons shared by Paragraph and Properties; no button is selected
@@ -492,7 +479,7 @@ pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -
/// the Type tool's anchor and caret past the characters inserted at or before them).
pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, String> {
text_style(app).ok_or("select some type first")?;
run_on_paragraphs(app, "text.convertListToText", json!({}))
run_on(app, "text.convertListToText", json!({}), false)
}
/// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs
@@ -1035,3 +1035,58 @@ fn reset_panel_leaves_an_undo_group_open_in_another_document() {
app.run("edit.undoGroup.end", json!({})).unwrap();
assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step");
}
/// With the Selection tool a selected frame of a threaded story stands for the whole story: the
/// Paragraph panel's changes and Reset Panel reach its locked and hidden frames, each in one undo
/// step, other type keeps its paragraphs, and the alignment buttons read every frame.
#[test]
fn selection_tool_paragraph_changes_reach_every_frame_of_the_story() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 900, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12);
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap();
let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
let paragraphs = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).clone()).collect::<Vec<_>>(),
_ => panic!("not text"),
};
let undo_steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
for id in [a, b, c] {
assert!(!paragraphs(&app, id).is_empty(), "frame {id} holds text");
}
let steps = undo_steps(&app);
format(&mut app, json!({"spaceBefore": 4}));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| (p.space_before - 4.0).abs() < 1e-9), "frame {id} takes the space");
}
assert!(paragraphs(&app, other).iter().all(|p| p.space_before.abs() < 1e-9), "other type keeps its paragraphs");
assert_eq!(shared_para_field(&app, |p| p.space_before), Some(4.0));
align(&mut app, "center").unwrap();
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| p.justify == Justify::Center), "frame {id} is centered");
}
assert!(paragraphs(&app, other).iter().all(|p| p.justify != Justify::Center));
assert_eq!(selected_alignment(&app, &ParaStyle::default()), Some(Justify::Center));
// A hidden frame aligned otherwise leaves no alignment button on.
app.run("text.setStyle", json!({"ids": [c], "justify": "right"})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(selected_alignment(&app, &ParaStyle::default()), None);
let steps = undo_steps(&app);
reset_panel(&mut app);
assert_eq!(undo_steps(&app), steps + 1, "Reset Panel is one undo step");
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| p.space_before.abs() < 1e-9 && p.justify == Justify::Auto), "frame {id} is reset");
}
}
+10 -3
View File
@@ -167,15 +167,19 @@ pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weig
/// "Stroke"): a click toggles the Stroke panel in a popover under it, and a drag scrubs the weight
/// field after it ([`crate::scrub`]).
pub(crate) fn link(app: &mut VectorcraftApp, ui: &mut Ui, label: &str) {
let t = Tokens::get(ui.ctx());
// A drag on it scrubs the weight field after it (#4088).
let resp = crate::scrub::link_label(ui, egui::RichText::new(label).size(12.0).color(t.text).underline()).on_hover_text(tl!("Stroke options"));
let resp = crate::scrub::link_label(ui, link_text(ui, label)).on_hover_text(tl!("Stroke options"));
widgets::popover(&resp, resp.clicked(), |ui| {
ui.set_width(260.0);
show(app, ui);
});
}
/// The text of the Stroke [`link`] labeled `label`, as it draws it and the Control bar measures it.
pub(crate) fn link_text(ui: &Ui, label: &str) -> egui::RichText {
egui::RichText::new(label).size(12.0).color(Tokens::get(ui.ctx()).text).underline()
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let st = current_stroke(app);
@@ -491,12 +495,15 @@ pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color
ui.painter().with_clip_rect(r).add(egui::Shape::mesh(mesh));
}
/// The width of the [`profile_dropdown`] button.
pub(crate) const PROFILE_DROPDOWN_WIDTH: f32 = 100.0;
/// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button
/// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the
/// id or name picked.
pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option<String> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(PROFILE_DROPDOWN_WIDTH, 24.0), Sense::click());
ui.painter().rect_filled(r, 2, t.input);
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside);
let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0));
+21 -3
View File
@@ -419,7 +419,9 @@ fn stops_json(stops: &[TabStop]) -> Value {
)
}
/// Run `text.tabs.set` with `stops` on the text the panel acts on. Returns false when it fails.
/// Run `text.tabs.set` with `stops` on the text the panel acts on: with the Selection tool, every
/// frame of the selected stories ([`super::character::selected_stories`]). Returns false when it
/// fails.
fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
@@ -430,7 +432,9 @@ fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool {
};
app.run("text.tabs.set", p).is_ok()
} else {
app.run("text.tabs.set", json!({"stops": stops_json(stops)})).is_ok()
let mut p = json!({"stops": stops_json(stops)});
super::character::target_selected_stories(app, &mut p);
app.run("text.tabs.set", p).is_ok()
}
}
@@ -727,6 +731,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
resp.on_hover_text(tl!("Click to add a tab stop, drag to move it, drag it off the ruler to delete it"));
}
/// Whether a paragraph of the whole story Clear All Tabs acts on has tab stops: after Select All in
/// threaded text, every frame of the edited story; with the Selection tool, every frame of the
/// selected stories ([`super::character::selected_stories`]).
fn story_has_tabs(app: &VectorcraftApp) -> bool {
let has_tabs = |t: &TextObject| (0..t.paragraph_count()).any(|i| !t.para_at(i).tabs.is_empty());
match (super::character::text_editing(app), super::character::text_story(app), app.session.active()) {
(Some(_), Some(story), Some(st)) => {
story.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if has_tabs(t)))
}
(Some(_), _, _) => false,
(None, _, _) => super::character::selected_story_texts(app).into_iter().any(has_tabs),
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((_, para, _)) = current_para(app) else {
ui.add_enabled(false, egui::Button::new(tl!("Select text")).frame(false));
@@ -742,7 +760,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if sel >= stops.len() {
sel = stops.len().saturating_sub(1);
}
if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty(), false)
if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty() || story_has_tabs(app), false)
&& let Err(e) = super::paragraph::run_on_paragraphs(app, "text.tabs.clear", json!({}))
{
app.status(e);
+37
View File
@@ -642,6 +642,43 @@ fn text_of(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject {
}
}
/// With the Selection tool, tab stops set in the panel go to every paragraph of every frame of a
/// selected threaded story, its locked and hidden frames included.
#[test]
fn selection_tool_tab_stops_reach_every_frame_of_the_story() {
let (mut app, frames) = split_story_app();
app.run("select.set", json!({"ids": [frames[1]]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [frames[2]]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [frames[0]]})).unwrap();
let stop = TabStop { position: 36.0, align: Default::default(), leader: String::new(), align_on: '.' };
assert!(apply(&mut app, std::slice::from_ref(&stop)));
for id in frames {
let t = text_of(&app, id);
assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs == [stop.clone()]), "frame {id} takes the stop");
}
}
/// With the Selection tool, Clear All Tabs is on when only another frame of the selected story has
/// tab stops, and clears them there.
#[test]
fn clear_all_tabs_is_on_when_another_frame_of_the_story_has_stops() {
let (mut app, [a, b, c]) = split_story_app();
app.run("text.tabs.set", json!({"ids": [c], "stops": [{"position": 36}]})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert!(current(&app).unwrap().1.is_empty(), "the selected frame has no stops");
assert!(story_has_tabs(&app));
super::super::paragraph::run_on_paragraphs(&mut app, "text.tabs.clear", json!({})).unwrap();
for id in [a, b, c] {
let t = text_of(&app, id);
assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs.is_empty()), "frame {id} has no stops");
}
assert!(!story_has_tabs(&app));
}
/// Each paragraph of the story of `frames` by its first word, with its left indent and first-line
/// position, counted once; each piece of a paragraph split between frames has the same values.
fn story_paragraphs(app: &VectorcraftApp, frames: &[u64]) -> Vec<(String, f64, f64)> {
+11
View File
@@ -42,6 +42,9 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) {
// Read-only: whether VECTORCRAFT_TITLEBAR chose this window's frame (System Title Bar's dimmed
// row and VectorCraft Title Bar on Every Desktop's note).
fields.insert("__titlebarFromEnv".into(), json!(app.titlebar_from_env));
// Read-only: whether the desktop would draw this window's frame with System Title Bar on or off
// while VectorCraft Title Bar on Every Desktop is off (System Title Bar's note).
fields.insert("__desktopKeepsFrame".into(), json!(app.desktop_keeps_frame));
fields.insert("__search".into(), json!(""));
fields.insert("__focusSearch".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields });
@@ -535,6 +538,14 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog,
if !APP_TITLE_BAR_SHOWN {
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
}
// Where the desktop draws the frame, the desktop's title bar stays with this
// option on or off until VectorCraft Title Bar on Every Desktop is turned on.
if APP_TITLE_BAR_SHOWN && d.bool("__desktopKeepsFrame") && !d.bool("__titlebarFromEnv") && !d.bool("appTitleBar") {
let note = tl!(
"This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off."
);
ui.label(egui::RichText::new(note).color(t.text_dim).size(11.0));
}
}
}
// Linux and the BSDs only, where the desktop may draw the frame: dimmed while System
+40
View File
@@ -581,6 +581,46 @@ fn system_title_bar_is_dimmed_while_the_environment_variable_chooses_the_frame()
}
}
/// Where the desktop draws the frame with System Title Bar on or off (KDE, Sway, X11, a window
/// opened through XWayland), a note under System Title Bar says so while VectorCraft Title Bar on
/// Every Desktop is off and VECTORCRAFT_TITLEBAR is unset. Linux and the BSDs only.
#[test]
fn system_title_bar_says_when_the_desktop_keeps_the_frame() {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let note = "This desktop draws the window's title bar with this option on or off while VectorCraft Title Bar on Every Desktop is off.";
let supported = cfg!(not(any(target_arch = "wasm32", target_os = "macos", target_os = "windows")));
for keeps in [false, true] {
for from_env in [false, true] {
for (app_title_bar, system) in [(false, false), (false, true), (true, false), (true, true)] {
let mut a = app();
a.desktop_keeps_frame = keeps;
a.titlebar_from_env = from_env;
open(&mut a, Some("User Interface"));
let fields = &mut a.ui.dialog.as_mut().unwrap().fields;
fields.insert("appTitleBar".into(), json!(app_title_bar));
fields.insert("systemTitleBar".into(), json!(system));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
out.textures_delta.clear();
let mut shown = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
let expected = supported && keeps && !from_env && !app_title_bar;
let label = format!("keeps {keeps}, variable {from_env}, app title bar {app_title_bar}, system {system}: {shown:?}");
assert_eq!(shown.iter().any(|t| t == note), expected, "{label}");
}
}
}
}
/// Performance › Linux Display Server (#2028): on Linux it shows the drawing-tablet note, and
/// X11 (XWayland) When a Pen Is Attached is applied by OK and saved under the key the desktop
/// app reads before the window opens.
+82 -8
View File
@@ -29,6 +29,17 @@ pub struct Timer {
/// The recovery folders that failure messages named, latest last, at most [`NAMED`]: replies to
/// automation clients hide those outside their read roots.
pub named: Vec<String>,
/// The restored documents whose linked files are still to be asked about, next first
/// ([`ask_next`]).
links: Vec<LinkQuestions>,
}
/// The questions about the linked files of a restored document (by uid): the missing files, then
/// the modified images (ids) to offer for update ([`crate::dialogs::missing_links::ask_next`]).
struct LinkQuestions {
uid: u64,
missing: Vec<Value>,
modified: Vec<Value>,
}
/// How many recovery folders [`Timer::named`] keeps.
@@ -187,28 +198,91 @@ fn named(app: &mut VectorcraftApp, folder: &str) {
}
/// The startup dialog's OK (Restore) or Discard: every copy it lists.
///
/// After Restore the status bar shows how many documents were restored, followed by the first of
/// the restored documents' notes: for each document in the dialog's order, the lines about its
/// linked files, then what reading the copy left out, then its other warnings, each named with
/// its document's title when more than one was restored. Hovering the message lists them all. The
/// questions about the documents' linked files are asked one document at a time ([`ask_next`]).
/// When a copy can't be restored, the status bar shows the error, and its hover lists the count
/// and the notes of the documents that were restored.
pub fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
app.ui.dialog = None;
let id = if d.bool("discard") { "file.recovery.discard" } else { "file.recovery.restore" };
let discard = d.bool("discard");
let id = if discard { "file.recovery.discard" } else { "file.recovery.restore" };
let files: Vec<String> =
d.fields.get("copies").and_then(Value::as_array).into_iter().flatten().filter_map(|c| c["file"].as_str()).map(str::to_string).collect();
let (mut done, mut errors) = (vec![], vec![]);
let (mut done, mut errors, mut opened) = (vec![], vec![], vec![]);
for file in files {
match app.run(id, json!({ "file": file })) {
Ok(r) => {
if let Some(restored) = r["restored"].as_array().and_then(|v| v.first()) {
crate::dialogs::missing_links::after_open(app, restored, Vec::new());
for restored in r["restored"].as_array().into_iter().flatten() {
opened.push(restored_notes(app, restored));
}
let failed = r["failed"].as_array().into_iter().flatten();
errors.extend(failed.filter_map(|f| f["error"].as_str()).map(str::to_string));
done.push(r);
}
Err(e) => errors.push(e),
}
}
if !errors.is_empty() {
return Err(errors.join("; "));
if discard {
if !errors.is_empty() {
return Err(errors.join("; "));
}
app.status(recovered_status(true, done.len()));
return Ok(json!(done));
}
let summary = recovered_status(false, opened.len());
let titled = opened.len() > 1;
let restored = !opened.is_empty();
let notes: Vec<String> = opened
.into_iter()
.flat_map(|(title, notes)| {
notes.into_iter().map(move |n| if titled { crate::i18n::fill_once("{path}: {e}", &[("path", &title), ("e", &n)]) } else { n })
})
.collect();
let result = if errors.is_empty() {
let status = match notes.first() {
Some(first) => crate::io::opened_after_notice(&summary, notes.len(), first),
None => summary,
};
crate::io::show_with_notes(app, status, notes);
Ok(json!(done))
} else {
let e = errors.join("; ");
if restored {
crate::io::show_with_notes(app, e.clone(), std::iter::once(summary).chain(notes).collect());
}
Err(e)
};
crate::dialogs::settle(app);
result
}
/// A document Restore just opened (`file.recovery.restore`'s entry `r`, the current document) →
/// its title and notes ([`confirm`]). Its Missing Fonts dialog and the questions about its linked
/// files wait for their turn.
fn restored_notes(app: &mut VectorcraftApp, r: &Value) -> (String, Vec<String>) {
crate::dialogs::missing_fonts::after_open(app, r);
let (mut notes, missing, modified) = crate::dialogs::missing_links::questions(app, r);
let uid = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| app.session.documents().get(i)).map(|st| st.uid);
if let Some(uid) = uid.filter(|_| !missing.is_empty() || !modified.is_empty()) {
app.recovery.links.push(LinkQuestions { uid, missing, modified });
}
notes.extend(crate::io::open_notes(app, r));
(r["title"].as_str().unwrap_or_default().to_string(), notes)
}
/// With no dialog open: the questions about the linked files of the next restored document that
/// is still open, with that document made the current one (the answers change it).
pub(crate) fn ask_next(app: &mut VectorcraftApp) {
while app.ui.dialog.is_none() && !app.recovery.links.is_empty() {
let q = app.recovery.links.remove(0);
let Some(i) = app.session.documents().iter().position(|st| st.uid == q.uid) else { continue };
crate::chrome::show_document(app, i);
crate::dialogs::missing_links::ask_next(app, q.missing, q.modified, Vec::new());
}
app.status(recovered_status(id == "file.recovery.discard", done.len()));
Ok(json!(done))
}
/// The status bar's line for `n` recovered documents discarded or restored.
+6
View File
@@ -618,6 +618,11 @@ pub struct UiState {
/// Shift-click on a set's name added. Delete Selection deletes them. Not saved.
#[serde(skip)]
pub action_sets_selected: Vec<usize>,
/// The Actions panel shows its actions as buttons (panel menu › Button Mode,
/// [`crate::panels::actions::button_mode`]) instead of its list; kept with the UI preferences,
/// and off in preferences saved without it.
#[serde(default)]
pub action_button_mode: bool,
/// Recording in progress: (set index, action name, journal length when recording started).
#[serde(skip)]
pub recording: Option<(usize, String, usize)>,
@@ -1171,6 +1176,7 @@ impl Default for UiState {
action_sets: crate::panels::actions::default_sets(),
action_set: None,
action_sets_selected: vec![],
action_button_mode: false,
recording: None,
recording_into: None,
recording_at: None,
+100
View File
@@ -291,6 +291,54 @@ fn hovering_back_to_source_resumes_move_instead_of_copying() {
assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}");
}
/// Back in the source tab, the move resumes even when coming to the front measured the source's
/// type again in its own composite fonts, which replaces the document without editing it.
#[test]
fn hovering_back_to_a_source_whose_type_is_measured_again_resumes_move() {
let font = |roman: u32| {
json!({
"name": "CF Tab Drag",
"parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "JetBrains Mono", "size": roman}},
})
};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap();
app.run("type.compositeFonts.set", json!({"fonts": [font(100)]})).unwrap();
app.run("text.create", json!({"x": 200, "y": 250, "text": "Ab12", "size": 20, "font": "CF Tab Drag"})).unwrap();
let id = vectorcraft_doc::NodeId(app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap());
app.run("file.new", json!({"width":400,"height":300,"name":"Target"})).unwrap();
app.run("type.compositeFonts.set", json!({"fonts": [font(50)]})).unwrap();
app.session.set_active(0);
let undo = app.session.active().unwrap().history.undo.len();
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0, vec![]);
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0));
frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1));
let left = app.session.documents()[0].doc.clone();
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(tabs[0].center())]);
frame(&mut app, &ctx, 1.4, vec![]);
assert_eq!(app.session.active_index(), Some(0));
assert!(!std::sync::Arc::ptr_eq(&app.session.documents()[0].doc, &left), "the source's type is measured again");
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.6, vec![button(drop, false)]);
let st = app.session.active().unwrap();
assert_eq!(st.selection.objects, vec![id]);
assert_eq!(st.history.undo.len(), undo + 1);
let bounds = st.doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}");
}
/// Verify Alt-drag transfers restored originals across tabs without retaining preview duplicates.
#[test]
fn alt_drag_can_continue_across_multiple_tabs_without_copying_the_source_preview() {
@@ -380,6 +428,58 @@ fn source_edits_target_closure_switches_and_focus_loss_cancel_stale_transfers()
}
}
/// Artwork held over the tab of a document with other assigned color profiles switches to it and
/// drops there: bringing those profiles to the front redraws the source without editing it.
#[test]
fn a_drop_on_a_document_with_other_assigned_profiles_copies_the_artwork() {
const CHILD: &str = "VECTORCRAFT_TEST_DRAG_ASSIGNED_PROFILES";
if std::env::var_os(CHILD).is_none() {
// Color settings are process-wide: change them in a separate test process.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "tests::document_drag::a_drop_on_a_document_with_other_assigned_profiles_copies_the_artwork", "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"isolated drag test failed:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
return;
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap();
app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap();
app.run("file.new", json!({"width":400,"height":300,"name":"Target"})).unwrap();
app.run("edit.assignProfile", json!({"cmyk": vectorcraft_color::cms::DEVICE_CMYK})).unwrap();
app.session.set_active(0);
let source = app.session.active().unwrap().doc.clone();
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0, vec![]);
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0));
frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
assert!(app.session.in_interaction());
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(app.session.active_index(), Some(1));
let undo = app.session.active().unwrap().history.undo.len();
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.0, vec![button(drop, false)]);
let placed = app.session.active().unwrap().selection.objects.len();
let steps = app.session.active().unwrap().history.undo.len();
assert_eq!((placed, steps), (1, undo + 1), "the drop places the artwork");
assert_eq!(app.session.documents()[0].doc, source);
}
/// Verify tab-strip relayout under a stationary pointer cannot trigger another document switch.
#[test]
fn scrolling_tabs_under_a_stationary_drag_does_not_cascade_to_other_documents() {
+19 -1
View File
@@ -522,7 +522,9 @@ fn the_wheel_steps_the_families_in_properties_and_the_control_bar() {
type Draw = fn(&mut VectorcraftApp, &mut egui::Ui);
let places: [(&str, Draw); 2] = [
("Properties", |app, ui| crate::panels::properties::type_sections(app, ui)),
("Control bar", |app, ui| crate::panels::character::control_bar(app, ui)),
("Control bar", |app, ui| {
crate::panels::character::control_bar(app, ui, 0.0);
}),
];
let at = |f: &str| FontDb::global().menu_family_list().iter().position(|x| x == f);
for (place, draw) in places {
@@ -1040,6 +1042,22 @@ fn open_menu_with(id: &str, left: f32, width: f32, screen: egui::Vec2, look: Men
assert!(frames < 1000, "the menu's samples are still rendering after {frames} frames");
shapes = frame(vec![]);
}
// egui places the menu by the size it had the frame before. When a name measured late (a face
// added for its script, a font found in the background) widens it on that frame, the menu sits
// where the narrower one fit until the next: draw until it stays put.
let filter_at = |shapes: &[egui::epaint::ClippedShape]| {
shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == crate::i18n::t("Any Kind") => Some(t.pos),
_ => None,
})
};
for _ in 0..100 {
let before = filter_at(&shapes);
shapes = frame(vec![]);
if filter_at(&shapes) == before && !crate::font_menu::wait_for_samples(&ctx) {
break;
}
}
let mut texts = vec![];
let mut samples = vec![];
let mut tints = vec![];
+87 -1
View File
@@ -299,6 +299,91 @@ fn home_scrolls_in_a_short_window() {
assert_eq!(app.session.documents().len(), 1, "the card opened its preset");
}
/// The visible text `text` on a tall Home screen frame drawn at `time` → its center.
fn learn_text_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, text: &str) -> egui::Pos2 {
let texts = visible_home_texts(app, ctx, (1000.0, 2400.0), time, vec![]);
texts.iter().find(|(t, _)| t == text).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{text} is shown: {texts:?}"))
}
/// A click at `at` on a tall Home screen frame, pressed at `time` and released a frame later.
fn click_home_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: egui::Pos2) {
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
visible_home_texts(app, ctx, (1000.0, 2400.0), time, vec![egui::Event::PointerMoved(at), press(true)]);
visible_home_texts(app, ctx, (1000.0, 2400.0), time + 0.05, vec![press(false)]);
}
/// The Home screen has a Learn section: the Quick skill guide card and every tutorial of the Learn
/// panel under its skill. A tutorial shows the Learn panel at its steps and the card shows the list
/// of skills; a second choice keeps the panel shown, and no document is opened.
#[test]
fn home_has_a_learn_section_that_opens_the_learn_panel() {
use crate::panels::learn::{self, Tab, View, catalog};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.0, vec![]);
let has = |s: &str| texts.iter().any(|(t, _)| t == s);
assert!(has("Learn") && has("Quick skill guide"), "{texts:?}");
for skill in catalog::SKILLS {
assert!(has(skill.title), "{} is shown", skill.title);
for tutorial in skill.tutorials {
assert!(has(tutorial.title), "{} is shown", tutorial.title);
}
}
assert!(!learn::visible(&app.ui), "the panel is not shown before a choice");
let at = learn_text_at(&mut app, &ctx, 0.5, "Draw paths with the Pen");
click_home_at(&mut app, &ctx, 1.0, at);
assert!(learn::visible(&app.ui), "a tutorial shows the Learn panel");
assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("pen-paths") });
assert!(app.session.documents().is_empty(), "no document is opened");
assert!(crate::menus::home_showing(&app), "Home stays");
let at = learn_text_at(&mut app, &ctx, 1.5, "Quick skill guide");
click_home_at(&mut app, &ctx, 2.0, at);
assert!(learn::visible(&app.ui), "a second choice keeps the panel shown");
assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }, "the card shows the list of skills");
assert!(app.session.documents().is_empty());
}
/// Home shown over open documents has the Learn section too; a tutorial chosen there leaves the
/// documents and the active one as they are.
#[test]
fn home_learn_section_over_open_documents_leaves_them_as_they_are() {
use crate::panels::learn::{self, Tab, View};
let mut app = app_with_doc();
app.run("file.new", json!({})).unwrap();
app.run("document.activate", json!({"index": 0})).unwrap();
app.run("app.home", json!({})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let at = learn_text_at(&mut app, &ctx, 0.0, "Add a gradient");
click_home_at(&mut app, &ctx, 0.5, at);
assert!(learn::visible(&app.ui));
assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") });
assert_eq!((app.session.documents().len(), app.session.active_index()), (2, Some(0)), "the documents stay as they are");
assert!(crate::menus::home_showing(&app), "Home stays over the documents");
}
/// The Home screen's Quick skill guide card works from the keyboard: with the keyboard focus, Enter
/// shows the Learn panel's list of skills.
#[test]
fn home_quick_skill_guide_card_works_from_the_keyboard() {
use crate::panels::learn::{self, Tab, View};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.0, vec![]);
let card = egui::Id::unique("home-quick-skill-guide");
ctx.memory_mut(|m| m.request_focus(card));
visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.1, vec![]);
assert!(ctx.memory(|m| m.has_focus(card)), "the card takes the keyboard focus");
let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.2, vec![key(true)]);
visible_home_texts(&mut app, &ctx, (1000.0, 2400.0), 0.3, vec![key(false)]);
assert!(learn::visible(&app.ui));
assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None });
assert!(app.session.documents().is_empty());
}
/// A file that is already open is not opened again, however it is asked for: Home › Recent Files,
/// File › Open Recent, File › Open, the Finder and a drop all focus its tab, unsaved edits and all
/// (#3455).
@@ -452,7 +537,8 @@ fn text_centre_x(app: &mut VectorcraftApp, text: &str) -> Option<(f32, f32)> {
app.canvas_rect = None;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0));
// Tall enough to draw the whole screen, Learn section included.
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 1600.0));
let raw = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui));
out.textures_delta.clear();
+112
View File
@@ -115,6 +115,118 @@ fn launching_after_a_crash_offers_the_copies() {
assert!(next.ui.dialog.is_none());
}
/// A 40×30 PNG of one color.
fn png(rgb: [u8; 3]) -> Vec<u8> {
let mut out = vec![];
let [r, g, b] = rgb;
image::RgbaImage::from_pixel(40, 30, image::Rgba([r, g, b, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// The copies a crash left of `n` documents, each with an image linked to a file in `dir`. The
/// files are deleted and the images' bytes are gone from the store, so each copy restores with a
/// warning about its image and a missing linked file. → the store.
fn crashed_with_lost_images(dir: &std::path::Path, n: u8) -> Arc<MemoryStore> {
let store = Arc::new(MemoryStore::default());
let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
for k in 0..n {
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let p = dir.join(format!("{k}.png"));
std::fs::write(&p, png([200, k * 100, 0])).unwrap();
app.run("file.place", json!({"path": p.to_string_lossy()})).unwrap();
}
app.run("file.recovery.save", json!({})).unwrap();
drop(app);
for k in 0..n {
std::fs::remove_file(dir.join(format!("{k}.png"))).unwrap();
}
for name in store.list().unwrap().into_iter().filter(|n| n.ends_with(".blob")) {
store.remove(&name).unwrap();
}
store
}
/// The app launched after the crash, with the Recover Documents dialog open → the app and the
/// titles the restored documents get, in the dialog's order.
fn launched(store: &Arc<MemoryStore>) -> (VectorcraftApp, Vec<String>) {
let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
recovery::frame(&mut app, 0.0);
let d = app.ui.dialog.as_ref().expect("the Recover Documents dialog");
let titles = d.fields["copies"].as_array().unwrap().iter().map(|c| format!("{} [Recovered]", c["title"].as_str().unwrap())).collect();
(app, titles)
}
/// Is `note` the warning about a restored document's image that couldn't be read?
fn lost_image(note: &str) -> bool {
note.starts_with("the image “") && note.contains("” of the recovered document couldn't be read")
}
/// Restore keeps the warnings each restored document opened with: the status bar names the first
/// after the count, and its hover lists them all, each named with its document. The questions
/// about the missing linked files come one document at a time, with that document current.
#[test]
fn restore_keeps_each_documents_notes_and_asks_about_its_links_in_turn() {
let dir = scratch("restore-notes");
let store = crashed_with_lost_images(&dir, 2);
let (mut app, titles) = launched(&store);
dialogs::confirm(&mut app).unwrap();
let (status, notes) = app.ui.status_notes.clone().expect("the notes are kept");
assert_eq!(status, app.ui.status);
assert_eq!(notes.len(), 2, "{notes:?}");
for (note, title) in notes.iter().zip(&titles) {
let lost = note.strip_prefix(&format!("{title}: ")).unwrap_or_default();
assert!(lost_image(lost), "{note}");
}
assert_eq!(status, format!("Restored 2 recovered documents: save them to keep them. Opened with 2 notes: {}", notes[0]));
// The first document's missing file is asked about first, and the second's waits.
for title in &titles {
let d = app.ui.dialog.clone().expect("the missing file's question");
assert_eq!(d.kind, dialogs::missing_links::KIND);
assert_eq!(&app.session.doc().unwrap().title(), title);
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
dialogs::confirm(&mut app).unwrap();
}
assert!(app.ui.dialog.is_none());
assert_eq!(app.ui.status, status, "answering the questions keeps the line");
let _ = std::fs::remove_dir_all(&dir);
}
/// With one document restored, its notes are not named with its title.
#[test]
fn restoring_one_document_shows_its_notes_after_the_count() {
let dir = scratch("restore-one");
let store = crashed_with_lost_images(&dir, 1);
let (mut app, _) = launched(&store);
dialogs::confirm(&mut app).unwrap();
let (status, notes) = app.ui.status_notes.clone().expect("the notes are kept");
assert!(notes.len() == 1 && lost_image(&notes[0]), "{notes:?}");
assert_eq!(status, format!("Restored 1 recovered document: save it to keep it. Opened with 1 note: {}", notes[0]));
assert_eq!(app.ui.status, status);
let _ = std::fs::remove_dir_all(&dir);
}
/// A copy that can't be restored after another was: the status bar shows the error, and its hover
/// still lists the count and the notes of the document that was restored.
#[test]
fn a_failed_restore_keeps_the_notes_of_the_documents_restored_before_it() {
let dir = scratch("restore-partial");
let store = crashed_with_lost_images(&dir, 2);
let (mut app, _) = launched(&store);
let second = app.ui.dialog.as_ref().unwrap().fields["copies"][1]["file"].as_str().unwrap().to_string();
store.write(&format!("{second}.vectorcraft"), b"not a document").unwrap();
let e = dialogs::confirm(&mut app).unwrap_err();
assert_eq!(app.session.documents().len(), 1);
assert_eq!(app.ui.status, e);
let (status, notes) = app.ui.status_notes.clone().expect("the notes are kept");
assert_eq!(status, e);
assert_eq!(notes.len(), 2, "{notes:?}");
assert_eq!(notes[0], "Restored 1 recovered document: save it to keep it");
assert!(lost_image(&notes[1]), "{}", notes[1]);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::missing_links::KIND), "its missing file is asked about");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discard_at_launch_deletes_the_copies_and_cancel_keeps_them() {
let store = Arc::new(MemoryStore::default());
+97
View File
@@ -536,6 +536,103 @@ fn the_task_bar_follows_the_active_document() {
assert!(!locked(&app, rect), "the button doesn't lock the shape");
}
/// A blank canvas, a new document with a selection tool in use, offers Draw (Pencil) and Place
/// Image centered under the active artboard (under the canvas's center when the artboard is out of
/// view), with the handle and More Options but none of a selection's actions or its Fill, Stroke,
/// Recolor Artwork and Lock. Other tools, artwork in the document and a selection each leave the
/// blank canvas actions out; ruler guides do not count as artwork.
#[test]
fn a_blank_canvas_offers_draw_and_place_image() {
let mut app = app();
let board = app.session.active().unwrap().doc.artboards[0].rect;
// The artboard at 50% in the middle of the window, after the first frame fits it in.
bar(&mut app);
let view = app.view_mut().unwrap();
(view.center, view.zoom) = (board.center(), 0.5);
for tool in ["selection", "directSelection", "groupSelection"] {
app.run("tool.select", json!({ "tool": tool })).unwrap();
let (ctx, texts) = bar_texts(&mut app);
assert!(at(&texts, "Draw (Pencil)").x < at(&texts, "Place Image").x, "{tool}: {texts:?}");
let shown = canvas::task_bar_rect(&ctx).unwrap();
let xf = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let bottom = xf.quad(board).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
let middle = xf.to_screen(board.center()).x;
assert!(shown.top() > bottom && (shown.center().x - middle).abs() < 2.0, "{tool}: {shown:?} centered under {board:?}");
assert!(app.canvas_rect.unwrap().contains_rect(shown), "{tool}: {shown:?} on the canvas");
}
// The artboard out of view: under the canvas's center.
app.view_mut().unwrap().center = board.center() + vectorcraft_geom::Vec2::new(5000.0, 5000.0);
let (ctx, _) = bar(&mut app);
let (shown, middle) = (canvas::task_bar_rect(&ctx).unwrap(), app.canvas_rect.unwrap().center());
assert!((shown.center().x - middle.x).abs() < 2.0 && shown.top() > middle.y && shown.top() < middle.y + 60.0, "{shown:?} under {middle:?}");
app.view_mut().unwrap().center = board.center();
let tree = crate::widgets::accessibility_tests::tree(|ui| canvas::show(&mut app, ui));
for label in ["Draw (Pencil)", "Place Image", "More Options"] {
assert_eq!(crate::widgets::accessibility_tests::named(&tree, label).role(), egui::accesskit::Role::Button, "{label}");
}
let named = |label: &str| tree.iter().any(|n| n.label().is_some_and(|l| l.starts_with(label)));
for label in ["Fill", "Stroke", "Recolor Artwork", "Lock", "Duplicate"] {
assert!(!named(label), "{label} is left out");
}
for tool in ["pen", "rectangle", "type", "artboard"] {
app.run("tool.select", json!({ "tool": tool })).unwrap();
let (_, texts) = bar_texts(&mut app);
assert!(!has(&texts, "Draw (Pencil)") && !has(&texts, "Place Image"), "{tool}: {texts:?}");
}
app.run("tool.select", json!({"tool": "selection"})).unwrap();
let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap());
select(&mut app, &[rect]);
let (_, texts) = bar_texts(&mut app);
assert!(has(&texts, "Duplicate") && !has(&texts, "Draw (Pencil)"), "{texts:?}");
select(&mut app, &[]);
let (ctx, _) = bar(&mut app);
assert!(canvas::task_bar_rect(&ctx).is_none(), "artwork with nothing selected: no bar");
select(&mut app, &[rect]);
app.run("edit.clear", json!({})).unwrap();
app.run("guide.add", json!({"pos": 50})).unwrap();
assert!(has(&bar_texts(&mut app).1, "Draw (Pencil)"), "a ruler guide is not artwork");
}
/// Draw (Pencil) switches to the Pencil tool. Place Image picks files for the Place dialog;
/// canceling the pick leaves the document as it was.
#[test]
fn the_blank_canvas_bar_draws_with_the_pencil_and_places_an_image() {
let mut app = app();
app.run("tool.select", json!({"tool": "selection"})).unwrap();
click(&mut app, "Draw (Pencil)");
assert_eq!(app.session.tool_id(), "pencil");
app.run("tool.select", json!({"tool": "selection"})).unwrap();
app.services.pick_open = Some(Box::new(|_: &crate::FilePick| None));
click(&mut app, "Place Image");
assert!(app.ui.dialog.is_none(), "canceled: no Place dialog");
assert!(app.session.active().unwrap().doc.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "nothing placed");
let path = std::env::temp_dir().join("vectorcraft-task-bar-place.png").to_string_lossy().into_owned();
let picked = path.clone();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone())));
click(&mut app, "Place Image");
let d = app.ui.dialog.take().expect("the Place dialog");
assert_eq!((d.kind.as_str(), &d.fields["files"]), (crate::dialogs::place::KIND, &json!([{ "path": path }])));
}
/// On a canvas too narrow for one row, the blank canvas bar's actions wrap onto a second row and the
/// bar stays whole on the canvas.
#[test]
fn the_blank_canvas_bar_wraps_on_a_narrow_canvas() {
let mut app = app();
app.run("tool.select", json!({"tool": "selection"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let (texts, r, canvas) = bar_texts_on_screen(&mut app, &ctx, vec2(240.0, 400.0), 3);
assert!(canvas.contains_rect(r), "{r:?} on {canvas:?}");
let (draw, place) = (at(&texts, "Draw (Pencil)"), at(&texts, "Place Image"));
assert!(place.y > draw.y + 20.0, "Place Image wraps under Draw (Pencil): {draw:?} {place:?}");
}
/// The Type tools whose bar shows the font and Fill controls.
const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"];
+5 -1
View File
@@ -571,7 +571,10 @@ pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String {
fn add_craft_fonts(fonts: &mut FontDefinitions) {
// The name of each face's copy in each family.
let mut copies: std::collections::HashMap<(FontFamily, String), String> = std::collections::HashMap::new();
for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.is_georgian() || f.scripts.contains(&"Deva")) {
for f in vectorcraft_text::CRAFT_FONTS
.iter()
.filter(|f| f.is_japanese() || f.is_georgian() || f.scripts.contains(&"Deva") || f.scripts.contains(&"Gujr"))
{
let name = craft_font_name(f);
for (copy, data, families) in crate::ui_fonts::baseline_copies(fonts, &name, &FontData::from_static(f.bytes)) {
for family in families {
@@ -589,6 +592,7 @@ fn add_craft_fonts(fonts: &mut FontDefinitions) {
stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(copy));
stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(copy));
stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(copy));
stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Gujr")).map(copy));
}
}