mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Tabs: Repeat Tab measures the first stop from the left indent
Repeat Tab on a paragraph's first tab stop spaced the copies by the stop's distance from the ruler's 0. The reference app measures that distance from the paragraph's left indent. With a left indent of 20 pt and one stop at 50 pt, the copies were 50 pt apart instead of 30. Repeat Tab now spaces the copies of the first stop by its distance from the left indent of the paragraph whose stops the Tabs panel shows: the paragraph the caret is in while the Type tool edits text, otherwise the first paragraph of the first selected type object. A stop left of the indent repeats at its distance from the indent: a stop at 5 pt in a paragraph indented 20 pt repeats every 15 pt. The reference app's help page does not cover this case, so this follows VectorCraft's own rule. The copies of a later stop are still spaced by its distance from the stop before it, and the limits of 20 copies and 2,000 pt stay. Docs: docs/target-app-parity.md.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
|
||||
use egui::{Sense, Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind, TabAlign, TabStop, TextKind};
|
||||
use vectorcraft_doc::{Node, NodeKind, ParaStyle, TabAlign, TabStop, TextKind};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
@@ -33,6 +33,11 @@ const RULER_LEFT: f32 = 10.0 + 4.0;
|
||||
/// first selected text object's first paragraph) and the ruler span in points (the frame width for
|
||||
/// area type).
|
||||
fn current(app: &VectorcraftApp) -> Option<(Node, Vec<TabStop>, f64)> {
|
||||
current_para(app).map(|(n, para, span)| (n, para.tabs, span))
|
||||
}
|
||||
|
||||
/// As [`current`], with all the attributes of the paragraph whose stops the panel shows.
|
||||
fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> {
|
||||
let editing = super::character::text_editing(app);
|
||||
let n = match editing {
|
||||
Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(),
|
||||
@@ -44,8 +49,8 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec<TabStop>, f64)> {
|
||||
TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
|
||||
_ => 360.0,
|
||||
};
|
||||
let tabs = t.para_at(para).tabs.clone();
|
||||
Some((n, tabs, span.max(72.0)))
|
||||
let para = t.para_at(para).clone();
|
||||
Some((n, para, span.max(72.0)))
|
||||
}
|
||||
|
||||
/// Position Panel Above Text: float the panel just above text `n`, as wide as its ruler needs to
|
||||
@@ -249,10 +254,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some((_, mut stops, _)) = current(app) else {
|
||||
let Some((_, para, _)) = current_para(app) else {
|
||||
ui.add_enabled(false, egui::Button::new(tl!("Select text")).frame(false));
|
||||
return;
|
||||
};
|
||||
let mut stops = para.tabs;
|
||||
// Clamp the selection to the last stop, as `show` does for the ruler.
|
||||
let mut sel: usize = pstate(ui.ctx(), "tabs-sel");
|
||||
if sel >= stops.len() {
|
||||
@@ -267,10 +273,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
stops.remove(sel);
|
||||
apply(app, &stops);
|
||||
}
|
||||
// Repeat Tab: copies of the selected stop at its distance from the previous one, across the ruler.
|
||||
// Repeat Tab: copies of the selected stop across the ruler, spaced by its distance from the
|
||||
// previous stop, or from the paragraph's left indent when it is the first stop.
|
||||
if menu_item(ui, tl!("Repeat Tab"), sel < stops.len(), false) {
|
||||
let prev = if sel == 0 { 0.0 } else { stops[sel - 1].position };
|
||||
let step = (stops[sel].position - prev).max(1.0);
|
||||
let step = match sel {
|
||||
0 => (stops[0].position - para.left_indent).abs(),
|
||||
_ => stops[sel].position - stops[sel - 1].position,
|
||||
}
|
||||
.max(1.0);
|
||||
let base = stops[sel].clone();
|
||||
stops.truncate(sel + 1);
|
||||
let mut pos = base.position + step;
|
||||
@@ -469,6 +479,43 @@ mod tests {
|
||||
assert_eq!(positions(&app).len(), 21);
|
||||
}
|
||||
|
||||
/// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's
|
||||
/// left indent, from the paragraph the caret is in while the Type tool edits text.
|
||||
#[test]
|
||||
fn repeat_tab_measures_the_first_stop_from_the_left_indent() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b\nc d", "area": {"width": 300, "height": 100}})).unwrap();
|
||||
app.session.execute("text.setFormat", &json!({"leftIndent": 20})).unwrap();
|
||||
app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let positions = |app: &VectorcraftApp| current(app).unwrap().1.iter().map(|s| s.position).collect::<Vec<_>>();
|
||||
// 30 pt from the indent at 20: copies at 80, 110, ...
|
||||
set_pstate(&ctx, "tabs-sel", 0usize);
|
||||
choose(&ctx, &mut app, "Repeat Tab");
|
||||
let repeated = positions(&app);
|
||||
assert_eq!((repeated.len(), &repeated[..4]), (21, &[50.0, 80.0, 110.0, 140.0][..]));
|
||||
// A stop left of the indent: 15 pt from the indent at 20.
|
||||
app.session.execute("text.tabs.set", &json!({"stops": [{"position": 5}]})).unwrap();
|
||||
choose(&ctx, &mut app, "Repeat Tab");
|
||||
assert_eq!(positions(&app)[..4], [5.0, 20.0, 35.0, 50.0]);
|
||||
// The Type tool's caret in the second paragraph, indented 40 pt: its stop at 50 repeats
|
||||
// every 10 pt, and the first paragraph keeps its stop.
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 20, "text": "a b\nc d"})).unwrap()["id"].clone();
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
app.run("text.setFormat", json!({"id": id, "leftIndent": 20})).unwrap();
|
||||
app.run("text.setFormat", json!({"id": id, "start": 4, "end": 7, "leftIndent": 40})).unwrap();
|
||||
app.run("text.tabs.set", json!({"id": id, "stops": [{"position": 50}]})).unwrap();
|
||||
app.session.set_tool_option("select", &json!({"start": 5, "end": 5}));
|
||||
choose(&ctx, &mut app, "Repeat Tab");
|
||||
assert_eq!(positions(&app)[..3], [50.0, 60.0, 70.0]);
|
||||
app.session.set_tool_option("select", &json!({"start": 0, "end": 0}));
|
||||
assert_eq!(positions(&app), [50.0]);
|
||||
}
|
||||
|
||||
/// After Delete Tab removes the last stop, the ruler shows the stop before it selected, and
|
||||
/// Delete Tab removes that one next.
|
||||
#[test]
|
||||
|
||||
@@ -618,7 +618,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.
|
||||
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object.
|
||||
The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options.
|
||||
The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it (or from the ruler's 0), and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again.
|
||||
The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again.
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
|
||||
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
|
||||
|
||||
Reference in New Issue
Block a user