mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 13:07:41 +00:00
Type: Tab and Shift+Tab move a list item down and up a level
Squashed from #5037 onto main.
This commit is contained in:
@@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> {
|
||||
let Some(&first) = run.first() else { continue };
|
||||
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
|
||||
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
|
||||
// A right-to-left paragraph's marker is drawn with its tab first and its number's
|
||||
// period at the number's left; the TEXT entity holds the characters in that order.
|
||||
// A right-to-left paragraph's marker is drawn with its number's period at the
|
||||
// number's left; the TEXT entity holds the characters in that order.
|
||||
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') {
|
||||
Some(number) if m.tab == m.glyphs.start => format!(".{number}"),
|
||||
Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"),
|
||||
_ => m.text.clone(),
|
||||
});
|
||||
let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
|
||||
+21
-4
@@ -1076,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() {
|
||||
|
||||
/// A numbered list paragraph's number and period are a TEXT entity of their own where they are
|
||||
/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the
|
||||
/// number's left.
|
||||
/// number's left, with the marker aligned left or right.
|
||||
#[test]
|
||||
fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let numbered = |text: &str, count: u32| {
|
||||
use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList};
|
||||
let numbered_aligned = |text: &str, count: u32, align: ListAlign| {
|
||||
let mut d = text_doc(text, 20.0);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align));
|
||||
t.para.left_indent = 30.0;
|
||||
t.para.first_line_indent = -30.0;
|
||||
t.list_count = count;
|
||||
@@ -1092,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>()
|
||||
};
|
||||
let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left);
|
||||
assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]);
|
||||
let rtl = numbered("שלום", 11);
|
||||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||||
assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");
|
||||
// Aligned right, the number keeps its order and moves right, against the text.
|
||||
let right = numbered_aligned("Item", 11, ListAlign::Right);
|
||||
assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0)));
|
||||
assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}");
|
||||
let rtl = numbered_aligned("שלום", 11, ListAlign::Right);
|
||||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||||
// A mixed paragraph: the period follows the paragraph's direction, not the script of the
|
||||
// words it holds, with either alignment.
|
||||
let marker = |text: &str, align: ListAlign| {
|
||||
let out = numbered_aligned(text, 11, align);
|
||||
out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default()
|
||||
};
|
||||
for align in [ListAlign::Left, ListAlign::Right] {
|
||||
assert_eq!(marker("Item שלום", align), "12.", "{align:?}");
|
||||
assert_eq!(marker("שלום Item", align), ".12", "{align:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
|
||||
|
||||
@@ -27,7 +27,10 @@ pub use para_style::{
|
||||
ParaStyle, SingleWordJustify,
|
||||
};
|
||||
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
|
||||
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
|
||||
pub use tabs_lists::{
|
||||
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
|
||||
TabStop,
|
||||
};
|
||||
pub use wrap::{resolve_wrap, wrap_work};
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
@@ -101,6 +104,11 @@ pub struct TextObject {
|
||||
/// file saves the count only when it is not 0.
|
||||
#[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub list_count: u32,
|
||||
/// Threaded text: as [`Self::list_count`], the counts the earlier frames carry into this frame
|
||||
/// at list levels 2 on, level 2 first ([`ListCounts::from_parts`]). Empty when they are all 0,
|
||||
/// and the file saves them only when not empty. A file's longer list opens with the first 8.
|
||||
#[serde(default, rename = "listLevelCounts", skip_serializing_if = "Vec::is_empty", deserialize_with = "tabs_lists::read_level_counts")]
|
||||
pub list_level_counts: Vec<u32>,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::area::{AreaOptions, PathAlign, PathEffect};
|
||||
use super::char_style::CharStyle;
|
||||
use super::para_style::ParaStyle;
|
||||
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
|
||||
use super::tabs_lists::ParaList;
|
||||
use super::tabs_lists::{ListCounts, ParaList};
|
||||
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
|
||||
|
||||
impl TextObject {
|
||||
@@ -93,10 +93,20 @@ impl TextObject {
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The list counts the story's earlier frames carry into this frame at each level
|
||||
/// ([`Self::list_count`] and [`Self::list_level_counts`]).
|
||||
pub fn list_counts(&self) -> ListCounts {
|
||||
ListCounts::from_parts(self.list_count, &self.list_level_counts)
|
||||
}
|
||||
/// Set the list counts the story's earlier frames carry into this frame ([`Self::list_counts`]).
|
||||
pub fn set_list_counts(&mut self, counts: ListCounts) {
|
||||
(self.list_count, self.list_level_counts) = counts.parts();
|
||||
}
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
|
||||
@@ -258,6 +258,12 @@ impl ParaStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The left indent the layout sets the paragraph at: its left indent, and for a paragraph in a
|
||||
/// list at a level past the first, [`ParaList::level_indent`] more.
|
||||
pub fn laid_out_left_indent(&self) -> f64 {
|
||||
self.left_indent + self.list.map_or(0.0, |l| l.level_indent())
|
||||
}
|
||||
|
||||
/// The leading of characters styled `st` in this paragraph, as the layout sets it: their own
|
||||
/// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their
|
||||
/// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is
|
||||
|
||||
@@ -155,14 +155,29 @@ fn roman(mut n: u32) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets
|
||||
/// and Numbering dialog's Alignment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListAlign {
|
||||
/// The marker starts where the first line starts: at the left indent plus the first-line
|
||||
/// indent.
|
||||
#[default]
|
||||
Left,
|
||||
/// The marker ends where the first line's text starts, against the text.
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the
|
||||
/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's
|
||||
/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual
|
||||
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
|
||||
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
|
||||
/// out as their defaults, and a build ignores the fields it doesn't know.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the
|
||||
/// first line's start, or against the text with [`ListAlign::Right`]. A paragraph at a level past
|
||||
/// the first ([`Self::level`]) is set [`LIST_LEVEL_INDENT`] further in for each level. The marker
|
||||
/// is not a character of the text, and an empty paragraph has none. A saved list reads the fields
|
||||
/// it leaves out as their defaults, and a build ignores the fields it doesn't know.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
/// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for
|
||||
@@ -172,17 +187,127 @@ pub struct ParaList {
|
||||
/// numbered list.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number: Option<NumberStyle>,
|
||||
/// Where the marker stands before the first line's text. The file saves it only when it is
|
||||
/// [`ListAlign::Right`].
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub align: ListAlign,
|
||||
/// The paragraph's level in the list, 1 to [`LIST_LEVELS`] (Tab and Shift+Tab at the start of
|
||||
/// the paragraph, Bullets and Numbering…'s Level): each level counts its own numbers
|
||||
/// ([`Self::next_marker`]). A value outside that range is read as the nearest level
|
||||
/// ([`Self::depth`]). The file saves it only when it is not 1.
|
||||
#[serde(skip_serializing_if = "is_first_level", deserialize_with = "read_level")]
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
impl Default for ParaList {
|
||||
/// A bulleted list with a dot, its marker aligned left, at level 1.
|
||||
fn default() -> Self {
|
||||
Self { bullet: Bullet::default(), number: None, align: ListAlign::default(), level: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `level` the first list level, which the file does not save?
|
||||
fn is_first_level(level: &u8) -> bool {
|
||||
*level == 1
|
||||
}
|
||||
|
||||
/// Reads a saved list level, a whole number, as the nearest of 1 to [`LIST_LEVELS`], so a level
|
||||
/// such as -1 or 300 opens the file at level 1 or 9. Anything else is an error.
|
||||
fn read_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u8, D::Error> {
|
||||
let level = match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.as_i64().map_or(LIST_LEVELS as i64, |n| n.clamp(1, LIST_LEVELS as i64)),
|
||||
_ => return Err(serde::de::Error::custom("a list level must be a whole number")),
|
||||
};
|
||||
Ok(level as u8)
|
||||
}
|
||||
|
||||
/// Reads a type object's saved counts at list levels 2 on ([`ListCounts::from_parts`]), keeping
|
||||
/// the first `LIST_LEVELS - 1` and skipping the rest of a longer list without storing it.
|
||||
pub(super) fn read_level_counts<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
|
||||
struct Counts;
|
||||
impl<'de> serde::de::Visitor<'de> for Counts {
|
||||
type Value = Vec<u32>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of list counts")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while out.len() < LIST_LEVELS - 1 {
|
||||
match seq.next_element::<u32>()? {
|
||||
Some(c) => out.push(c),
|
||||
None => return Ok(out),
|
||||
}
|
||||
}
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Counts)
|
||||
}
|
||||
|
||||
/// How many levels a list has (Bullets and Numbering…'s Level: 1 to 9).
|
||||
pub const LIST_LEVELS: usize = 9;
|
||||
|
||||
/// How much further in (pt) the layout sets a list paragraph for each level past the first: its
|
||||
/// left indent counts that much more, and the marker and the text move with it.
|
||||
pub const LIST_LEVEL_INDENT: f64 = 18.0;
|
||||
|
||||
/// The numbered paragraphs a story's list has counted at each level, level 1 first
|
||||
/// ([`ParaList::next_marker`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ListCounts(pub [u32; LIST_LEVELS]);
|
||||
|
||||
impl ListCounts {
|
||||
/// The counts with `first` at level 1 and `deeper` at levels 2 on, a missing level at 0, as a
|
||||
/// type object saves them ([`TextObject::list_count`], [`TextObject::list_level_counts`]).
|
||||
pub fn from_parts(first: u32, deeper: &[u32]) -> Self {
|
||||
let mut counts = [0; LIST_LEVELS];
|
||||
counts[0] = first;
|
||||
for (c, &d) in counts[1..].iter_mut().zip(deeper) {
|
||||
*c = d;
|
||||
}
|
||||
Self(counts)
|
||||
}
|
||||
|
||||
/// The count at level 1, and the counts at levels 2 on without the zeros that end them.
|
||||
pub fn parts(&self) -> (u32, Vec<u32>) {
|
||||
let deeper = &self.0[1..];
|
||||
let used = deeper.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1);
|
||||
(self.0[0], deeper[..used].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl ParaList {
|
||||
/// A bulleted list with `bullet`.
|
||||
pub fn bulleted(bullet: Bullet) -> Self {
|
||||
Self { bullet, number: None }
|
||||
Self { bullet, ..Self::default() }
|
||||
}
|
||||
|
||||
/// A numbered list with `style`.
|
||||
pub fn numbered(style: NumberStyle) -> Self {
|
||||
Self { bullet: Bullet::default(), number: Some(style) }
|
||||
Self { number: Some(style), ..Self::default() }
|
||||
}
|
||||
|
||||
/// This list with its marker at `align`.
|
||||
pub fn aligned(self, align: ListAlign) -> Self {
|
||||
Self { align, ..self }
|
||||
}
|
||||
|
||||
/// This list at level `level` (1 to [`LIST_LEVELS`]).
|
||||
pub fn at_level(self, level: u8) -> Self {
|
||||
Self { level, ..self }
|
||||
}
|
||||
|
||||
/// The list's level, 1 to [`LIST_LEVELS`]: [`Self::level`], a value outside the range taken
|
||||
/// as the nearest level.
|
||||
pub fn depth(&self) -> usize {
|
||||
usize::from(self.level).clamp(1, LIST_LEVELS)
|
||||
}
|
||||
|
||||
/// How much further in (pt) than its left indent the layout sets a paragraph of this list:
|
||||
/// [`LIST_LEVEL_INDENT`] for each level past the first.
|
||||
pub fn level_indent(&self) -> f64 {
|
||||
(self.depth() - 1) as f64 * LIST_LEVEL_INDENT
|
||||
}
|
||||
|
||||
/// The characters of the marker the layout draws before the paragraph: the bullet, or for a
|
||||
@@ -195,18 +320,26 @@ impl ParaList {
|
||||
}
|
||||
|
||||
/// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and
|
||||
/// which holds no text when `empty`, with `count` the numbered paragraphs counted before it.
|
||||
/// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has
|
||||
/// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the
|
||||
/// count as it is; a numbered paragraph adds one to the count and is numbered with it.
|
||||
pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> {
|
||||
/// which holds no text when `empty`, with `counts` the numbered paragraphs counted before it at
|
||||
/// each level. A paragraph in no list sets every count back to 0 and has no marker; an empty
|
||||
/// paragraph has none and leaves the counts as they are. A paragraph with text sets the counts
|
||||
/// of the levels deeper than its own back to 0, so a deeper paragraph after it counts from 1
|
||||
/// again; a bulleted one has its bullet and leaves the count of its level as it is, and a
|
||||
/// numbered one adds one to the count of its level and is numbered with it. A paragraph back
|
||||
/// at a shallower level then goes on with that level's count.
|
||||
pub fn next_marker(list: Option<ParaList>, empty: bool, counts: &mut ListCounts) -> Option<String> {
|
||||
let Some(list) = list else {
|
||||
*count = 0;
|
||||
*counts = ListCounts::default();
|
||||
return None;
|
||||
};
|
||||
if empty {
|
||||
return None;
|
||||
}
|
||||
let level = list.depth() - 1;
|
||||
for deeper in &mut counts.0[level + 1..] {
|
||||
*deeper = 0;
|
||||
}
|
||||
let count = &mut counts.0[level];
|
||||
if list.number.is_some() {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
|
||||
@@ -194,13 +194,13 @@ fn number_styles_write_their_numbers_and_roll_over() {
|
||||
assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV.");
|
||||
assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}");
|
||||
let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot)));
|
||||
let mut count = 0;
|
||||
let mut count = ListCounts::default();
|
||||
let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)]
|
||||
.iter()
|
||||
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count))
|
||||
.collect();
|
||||
assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(count.parts(), (1, vec![]));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash));
|
||||
assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits));
|
||||
@@ -211,6 +211,20 @@ fn number_styles_write_their_numbers_and_roll_over() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`;
|
||||
/// a list saved without it reads as Left.
|
||||
#[test]
|
||||
fn a_list_saves_its_marker_alignment_only_when_it_is_right() {
|
||||
let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right);
|
||||
assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left);
|
||||
assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"}));
|
||||
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right);
|
||||
assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left);
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"align": "center"})).is_err());
|
||||
}
|
||||
|
||||
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
|
||||
/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the
|
||||
/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters
|
||||
@@ -240,6 +254,70 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
|
||||
assert_eq!(t.typed_list_at(19), Some((16, letters)));
|
||||
}
|
||||
|
||||
/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1,
|
||||
/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in
|
||||
/// no list starts every level again. A level past the first sets the paragraph 18 pt further
|
||||
/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the
|
||||
/// nearest one. A type object's counts save level 1 and the deeper levels apart.
|
||||
#[test]
|
||||
fn list_levels_count_their_own_numbers_and_save_only_past_the_first() {
|
||||
let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
|
||||
let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level));
|
||||
let mut counts = ListCounts::default();
|
||||
let markers: Vec<Option<String>> = [
|
||||
(num(1), false),
|
||||
(num(2), false),
|
||||
(num(2), false),
|
||||
(num(3), false),
|
||||
(num(2), true),
|
||||
(num(2), false),
|
||||
(num(1), false),
|
||||
(num(2), false),
|
||||
(dot(1), false),
|
||||
(num(2), false),
|
||||
(num(1), false),
|
||||
(None, false),
|
||||
(num(2), false),
|
||||
]
|
||||
.iter()
|
||||
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts))
|
||||
.collect();
|
||||
let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."];
|
||||
assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::<Vec<_>>(), want);
|
||||
assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0]));
|
||||
assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1));
|
||||
assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]);
|
||||
assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]);
|
||||
let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() };
|
||||
assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0));
|
||||
assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"}));
|
||||
assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4}));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap()));
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": "two"})).is_err());
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": 2.5})).is_err());
|
||||
let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)];
|
||||
assert_eq!(
|
||||
levels.map(|l| read(serde_json::json!({ "level": l })).level),
|
||||
[1, 1, 9, 9, 9],
|
||||
"a saved level outside 1 to 9 reads as the nearest one"
|
||||
);
|
||||
let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(deep.parts(), (5, vec![0, 2]));
|
||||
assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![])));
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
t.set_list_counts(deep);
|
||||
assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep));
|
||||
let saved = serde_json::to_value(&t).unwrap();
|
||||
assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2])));
|
||||
t.set_list_counts(ListCounts::default());
|
||||
assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them");
|
||||
let mut saved = serde_json::to_value(&t).unwrap();
|
||||
saved["listLevelCounts"] = serde_json::json!((1..=20).collect::<Vec<u32>>());
|
||||
let opened = serde_json::from_value::<TextObject>(saved).unwrap();
|
||||
assert_eq!(opened.list_level_counts, (1..=8).collect::<Vec<u32>>(), "a file keeps only the counts of levels 2 to 9");
|
||||
}
|
||||
|
||||
/// In a threaded frame whose first paragraph continues from the frame before, the text at the
|
||||
/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing.
|
||||
/// The paragraphs after it still start lists.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1607,6 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
@@ -1895,6 +1896,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
system_layout: t.system_layout,
|
||||
fractional_widths: t.fractional_widths,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -195,6 +195,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
|| optical != t.optical_margin
|
||||
|| vertical != t.vertical
|
||||
|| part.list_count != t.list_count
|
||||
|| part.list_level_counts != t.list_level_counts
|
||||
|| continues != t.first_para_continues
|
||||
{
|
||||
t.runs = next.runs;
|
||||
@@ -203,6 +204,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
t.optical_margin = optical;
|
||||
t.vertical = vertical;
|
||||
t.list_count = part.list_count;
|
||||
t.list_level_counts = part.list_level_counts;
|
||||
t.first_para_continues = continues;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -216,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
|
||||
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
|
||||
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
|
||||
/// its thread) gets 0 and is laid out again.
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] or
|
||||
/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame
|
||||
/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is
|
||||
/// laid out again.
|
||||
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
let mut stale = vec![];
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.list_count != 0
|
||||
&& (t.list_count != 0 || !t.list_level_counts.is_empty())
|
||||
{
|
||||
stale.push(n.id);
|
||||
}
|
||||
@@ -240,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
for id in stale {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.list_count = 0;
|
||||
t.list_level_counts.clear();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -444,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
|
||||
{
|
||||
// Split as `take_overflow` splits it: the first part is what the frame shows.
|
||||
let from: &TextObject = t;
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
let parts = vectorcraft_text::thread::distribute_continuing_levels(
|
||||
vectorcraft_text::FontDb::global(),
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
from.list_counts(),
|
||||
);
|
||||
if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) {
|
||||
t.runs = head.runs.clone();
|
||||
@@ -485,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) {
|
||||
// `from`'s text split as if it flowed on into a second frame: the second part is what `from`
|
||||
// has no room for. Its share is measured as it is laid out, continuing a paragraph or not and
|
||||
// with its numbered list paragraphs counted on from the frames before it.
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
let parts = vectorcraft_text::thread::distribute_continuing_levels(
|
||||
db,
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
from.list_counts(),
|
||||
);
|
||||
let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return };
|
||||
let mut tail = from.clone();
|
||||
|
||||
@@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() {
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
|
||||
}
|
||||
|
||||
/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the
|
||||
/// count the frames before it reached at that level.
|
||||
#[test]
|
||||
fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
|
||||
let story = format!("{items}{}", TEXT.repeat(4));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap();
|
||||
let shown = [a, c].map(|id| text(&s, id));
|
||||
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}");
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
|
||||
assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..]));
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII."));
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
|
||||
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text");
|
||||
let hidden = shown[1][end..].trim_start_matches(' ');
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("type.createOutlines", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
|
||||
}
|
||||
|
||||
/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's
|
||||
/// art holds the text the frame shows, measured with the count the frames before it reached at
|
||||
/// that level, and the frame before it takes the rest.
|
||||
#[test]
|
||||
fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() {
|
||||
for command in ["symbol.new", "symbol.update"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
|
||||
let story = format!("{items}{}", TEXT.repeat(4));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14}))
|
||||
.unwrap();
|
||||
let shown = [a, c].map(|id| text(&s, id));
|
||||
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}");
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(laid.overflow, "{command}: the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
|
||||
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text");
|
||||
let hidden = shown[1][end..].trim_start_matches(' ');
|
||||
let name = if command == "symbol.update" {
|
||||
let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [mark]})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
||||
"Mark"
|
||||
} else {
|
||||
"Story"
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute(command, &json!({"name": name})).unwrap();
|
||||
let held = symbol_texts(&s, name);
|
||||
assert_eq!(held.iter().map(|t| t.trim_end()).collect::<Vec<_>>(), [shown[1][..end].trim_end()], "{command}: the symbol's art");
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest");
|
||||
}
|
||||
}
|
||||
|
||||
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
|
||||
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
|
||||
fn quoted_thread() -> (Session, u64, u64) {
|
||||
@@ -1325,6 +1393,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() {
|
||||
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
|
||||
}
|
||||
|
||||
/// List levels in a thread: a deeper level's count runs on into the next frame, which saves
|
||||
/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1.
|
||||
#[test]
|
||||
fn list_levels_count_on_through_a_thread() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap();
|
||||
let numbers =
|
||||
|t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>();
|
||||
let first = numbers(&frame(&s, a));
|
||||
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
|
||||
let expected: Vec<String> = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect();
|
||||
assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected);
|
||||
let b_counts = frame(&s, b).list_counts();
|
||||
assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1]));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened");
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
|
||||
assert_ne!(copy, NodeId(b));
|
||||
let pasted = frame(&s, copy.0);
|
||||
assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..]));
|
||||
assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1");
|
||||
}
|
||||
|
||||
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
|
||||
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
|
||||
#[test]
|
||||
|
||||
@@ -1279,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let cell = lay.frames.first()?;
|
||||
let line = lay.lines.first()?;
|
||||
let para = t.para_at(0);
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent;
|
||||
Some((line.x1 - line.x0, avail, line.end))
|
||||
}
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() {
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"}));
|
||||
// A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know.
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<vectorcraft_doc::ParaStyle>(json!({ "list": v })).unwrap().list;
|
||||
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
|
||||
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None));
|
||||
@@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square)));
|
||||
s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap();
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) });
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() });
|
||||
assert_eq!(list(&s, 0), letters);
|
||||
assert_eq!(numbers(&s), ["a.", "2.", "3."]);
|
||||
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
|
||||
@@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
|
||||
}
|
||||
|
||||
/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker
|
||||
/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values;
|
||||
/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes
|
||||
/// between bullets and numbers, and a call that puts a paragraph in a list gives it the
|
||||
/// alignment too. The alignment is saved only when it is right, opens with the file, and
|
||||
/// paragraph styles carry it both ways.
|
||||
#[test]
|
||||
fn list_markers_are_aligned_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let list = |s: &Session, i: usize| text(s).para_at(i).list;
|
||||
let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::<Vec<_>>();
|
||||
let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right));
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, None]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(align(&s), [right, left, None]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, None]);
|
||||
s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap();
|
||||
assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one");
|
||||
for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] {
|
||||
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing");
|
||||
// Bullets and numbers keep it; a call that makes a list sets it there too.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)));
|
||||
s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right)));
|
||||
s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"}));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right))));
|
||||
// A list turned off and on again starts aligned left.
|
||||
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(align(&s)[1], left);
|
||||
// A style captured from a list aligned right gives other lists that alignment, and one
|
||||
// captured from a list aligned left takes it back.
|
||||
let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap();
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!(t.para_at(0).list.map(|l| l.align), right);
|
||||
s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, left]);
|
||||
}
|
||||
|
||||
/// The text object `id`.
|
||||
fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
@@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// Convert to Text of a list whose markers are aligned right writes the same characters as for
|
||||
/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops:
|
||||
/// the text after the tab stays where it was, and the marker moves to the first line's start,
|
||||
/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went
|
||||
/// to the left indent) and wider (their tab went to the next default stop), and bullets.
|
||||
#[test]
|
||||
fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let text_glyphs = |s: &Session, id: u64| {
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let milli = |v: f64| (v * 1000.0).round() as i64;
|
||||
lay.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.len > 0 && !g.outline.elements().is_empty())
|
||||
.map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y)))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
for (list, converted) in [
|
||||
("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n")),
|
||||
("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::<Vec<_>>().join("\n")),
|
||||
] {
|
||||
let make = |s: &mut Session, align: &str| {
|
||||
let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()
|
||||
["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap();
|
||||
id
|
||||
};
|
||||
let (left, right) = (make(&mut s, "left"), make(&mut s, "right"));
|
||||
let drawn_left = drawn_glyphs(&s, left);
|
||||
let text_before = text_glyphs(&s, right);
|
||||
assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment");
|
||||
assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere");
|
||||
s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap();
|
||||
assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab");
|
||||
let after = text_glyphs(&s, right);
|
||||
assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was");
|
||||
assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them");
|
||||
assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays");
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left list paragraph converted to text starts with the number, its period and a
|
||||
/// tab, which are read first: the line draws its glyphs in the order it drew them with the
|
||||
/// marker, the number at the right with its period at its left. Letters and roman numerals
|
||||
@@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() {
|
||||
assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
|
||||
}
|
||||
|
||||
/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those
|
||||
/// a thread's earlier frames counted at each level included, and every glyph is drawn where it
|
||||
/// was: each converted paragraph's left indent takes its level's indent, with the plain left
|
||||
/// stop there.
|
||||
#[test]
|
||||
fn convert_to_text_keeps_deeper_list_levels_in_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let numbers = |s: &Session, id: u64| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
let text_in = "One\nTwo\nThree\nFour\nFive";
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap();
|
||||
let before = drawn_glyphs(&s, id);
|
||||
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive");
|
||||
let paras = (0..5)
|
||||
.map(|k| {
|
||||
let p = t.para_at(k);
|
||||
(p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::<Vec<_>>())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent");
|
||||
// A thread whose second frame goes on with the numbers of level 2.
|
||||
let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::<Vec<_>>().join("\n");
|
||||
let a =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
let first = numbers(&s, a).len();
|
||||
assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}");
|
||||
assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on");
|
||||
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
|
||||
s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames");
|
||||
let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::<Vec<_>>().join("\n");
|
||||
assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story);
|
||||
}
|
||||
|
||||
/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches
|
||||
/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a
|
||||
/// list turned off and on starts at level 1 again, each level counts its own numbers, and the
|
||||
/// level is saved past level 1, reopened and carried by paragraph styles.
|
||||
#[test]
|
||||
fn list_levels_are_set_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::<Vec<_>>();
|
||||
let numbers = |s: &Session| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
|
||||
s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(levels(&s)[1], Some(2));
|
||||
s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one");
|
||||
let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})];
|
||||
for p in bad {
|
||||
assert!(s.execute("text.setFormat", &p).is_err(), "{p}");
|
||||
}
|
||||
let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string();
|
||||
assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}");
|
||||
assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing");
|
||||
// A deeper paragraph counts from 1, and one back at a shallower level goes on.
|
||||
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
|
||||
// Bullets and numbers keep the level; a call that makes a list sets it there too.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3)));
|
||||
s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap();
|
||||
assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4)));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3}));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((0..4).map(|i| back.para_at(i).list).collect::<Vec<_>>(), (0..4).map(|i| text(&s).para_at(i).list).collect::<Vec<_>>());
|
||||
// A list turned off and on again starts at level 1.
|
||||
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic)));
|
||||
// A style captured from a paragraph at level 4 sets that level, and one captured at level 1
|
||||
// takes it back.
|
||||
s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(levels(&s)[0], Some(4));
|
||||
s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1); 4]);
|
||||
}
|
||||
|
||||
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
|
||||
#[test]
|
||||
fn create_outlines_makes_a_path_of_the_bullet() {
|
||||
|
||||
@@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The point origin sits where the alignment anchors the first line.
|
||||
let p0 = t.para_at(0);
|
||||
let (x0, x1) =
|
||||
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let (x0, x1) = lay
|
||||
.lines
|
||||
.first()
|
||||
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let ox = match p0.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
|
||||
Justify::Right | Justify::JustifyRight => x1,
|
||||
|
||||
@@ -151,6 +151,42 @@ fn return_in_the_type_tool_continues_the_paragraph_it_splits() {
|
||||
assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_at_the_start_of_a_list_item_moves_it_a_level_down_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = area(&mut s, "one\ntwo");
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
|
||||
s.select_tool("type", v).unwrap();
|
||||
// Click into the second line, then Home: the caret before "two".
|
||||
let t = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let l1 = &lay.lines[1];
|
||||
let at = t.xf * vectorcraft_geom::Point::new((l1.x0 + l1.x1) / 2.0, l1.baseline - 3.0);
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap();
|
||||
}
|
||||
s.tool_key(ToolKey::Home, Mods::default(), v).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.tool_key(ToolKey::Tab, Mods::default(), v).unwrap();
|
||||
let levels = |s: &Session| text(s, id).paragraph_styles().iter().map(|p| p.list.map(|l| l.level)).collect::<Vec<_>>();
|
||||
assert_eq!(levels(&s), [Some(1), Some(2)]);
|
||||
assert_eq!((text(&s, id).plain_text(), s.doc().unwrap().history.undo.len()), ("one\ntwo".to_string(), undo + 1));
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
let starts: Vec<f64> = lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].origin.x).collect();
|
||||
assert!((starts[1] - starts[0] - 18.0).abs() < 1e-6, "the item moved 18 pt in: {starts:?}");
|
||||
assert_eq!(lay.markers.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(), ["1.", "1."]);
|
||||
// Shift+Tab moves it back up, and typing then goes on at the caret.
|
||||
s.tool_key(ToolKey::Tab, Mods { shift: true, ..Mods::default() }, v).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1)]);
|
||||
s.tool_text("and ", v).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "one\nand two");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((text(&s, id).plain_text(), levels(&s)), ("one\ntwo".to_string(), vec![Some(1), Some(2)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_applies_to_one_paragraph() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1276,6 +1276,45 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() {
|
||||
assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required");
|
||||
}
|
||||
|
||||
/// A list typed with "1. " starts at level 1. Tab at the start of the item Return makes moves it
|
||||
/// to level 2, where it is numbered from 1, Return continues the list at that level, and
|
||||
/// Shift+Tab moves an item back up, where it goes on with level 1's count. "1. " typed at the
|
||||
/// start of an item at level 2 stays text.
|
||||
#[test]
|
||||
fn a_typed_list_takes_its_levels_from_tab_and_shift_tab() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let v = ViewInfo::default();
|
||||
let mut s = session();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let typed = |s: &mut Session, text: &str| {
|
||||
for c in text.chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
};
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
typed(&mut s, "1. Apples");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, Mods::default());
|
||||
typed(&mut s, "Pears");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
typed(&mut s, "Plums");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, shift);
|
||||
typed(&mut s, "Figs");
|
||||
let arabic = ParaList::numbered(NumberStyle::Arabic);
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.plain_text(), "Apples\nPears\nPlums\nFigs");
|
||||
assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [arabic, arabic.at_level(2), arabic.at_level(2), arabic].map(Some));
|
||||
assert_eq!(list_markers(&s, id), ["1.", "1.", "2.", "2."]);
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, Mods::default());
|
||||
typed(&mut s, "1. ");
|
||||
let t = obj(&s, id);
|
||||
assert_eq!((t.plain_text().as_str(), t.para_at(4).list), ("Apples\nPears\nPlums\nFigs\n1. ", Some(arabic.at_level(2))));
|
||||
}
|
||||
|
||||
/// A byte offset given as a negative or fractional number is an error, not the end of the text
|
||||
/// (`at`) or its start or end (`start`, `end`), and the text is left as it was. Null keeps the default.
|
||||
#[test]
|
||||
@@ -2318,6 +2357,18 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_area_to_point_keeps_a_deeper_list_item_in_place() {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Item", "size": 18, "area": {"width": 150, "height": 200}})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "listLevel": 3})).unwrap();
|
||||
let before = first_glyph_doc_pos(&obj(&s, id));
|
||||
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
|
||||
let after = first_glyph_doc_pos(&obj(&s, id));
|
||||
assert!((after - before).hypot() < 0.01, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_listed_with_params() {
|
||||
let s = session();
|
||||
|
||||
@@ -470,6 +470,21 @@ fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() {
|
||||
assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::<Vec<_>>(), 0.1);
|
||||
}
|
||||
|
||||
/// The numbers of list paragraphs at a deeper level are written as text where the canvas draws
|
||||
/// them, 18 pt further in, each level with its own count.
|
||||
#[test]
|
||||
fn numbers_at_a_deeper_list_level_are_written_as_text_where_the_canvas_draws_them() {
|
||||
let number = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
|
||||
let d = doc(vec![listed("first\nsecond\nthird\nfourth", &[number(1), number(2), number(2), number(1)])]);
|
||||
let origins = marker_origins(&d);
|
||||
let x = |m: usize| origins.get(m).and_then(|o| o.first()).map_or(f64::NAN, |p| p.x);
|
||||
assert!((x(1) - x(0) - 18.0).abs() < 1e-6 && (x(3) - x(0)).abs() < 1e-6, "{origins:?}");
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||||
let all = texts(&back).concat();
|
||||
assert!(before(&all, "1.", "first") && before(&all, "2.", "third") && all.matches("2.").count() == 2, "{all}");
|
||||
assert_markers_in_place(&d, &back, &["1.", "1.", "2.", "2."], 0.1);
|
||||
}
|
||||
|
||||
/// A number set in oldstyle figures (the first character's OpenType features) is written as its
|
||||
/// digits, though the character map gives the digits other glyphs. The reopened type has no
|
||||
/// OpenType features and sets lining figures, so only where each number starts is compared.
|
||||
|
||||
@@ -435,6 +435,17 @@ fn numbers_are_written_as_their_characters_where_they_are_drawn() {
|
||||
assert!(at(">I.\t<") < at("alpha") && at("alpha") < at(">II.\t<") && at(">II.\t<") < at("zeta") && at("zeta") < at(">III.\t<"), "{svg}");
|
||||
assert!(!svg.contains(">-<"), "{svg}");
|
||||
assert_drawn_in_place(&d);
|
||||
// Aligned right: the gap comes before each number, which a viewer draws where the canvas does,
|
||||
// against the text.
|
||||
let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta\neta", style(12.0));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) };
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(vectorcraft_doc::text::ListAlign::Right));
|
||||
t.para.left_indent = 18.0;
|
||||
t.para.first_line_indent = -18.0;
|
||||
let d = doc(t);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.contains(">III.<") && !svg.contains(">III.\t<") && !svg.contains(">-<"), "{svg}");
|
||||
assert_drawn_in_place(&d);
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((250.0, 50.0));
|
||||
|
||||
+20
-11
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2};
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
use vectorcraft_doc::text::Kashida;
|
||||
use vectorcraft_doc::text::{Kashida, ListAlign};
|
||||
use vectorcraft_doc::{
|
||||
Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
|
||||
RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject,
|
||||
@@ -243,12 +243,16 @@ impl Ctx<'_> {
|
||||
/// before its glyphs `sg` and return how many glyphs went in (none for a paragraph without a
|
||||
/// marker). The marker's characters are set in
|
||||
/// [`vectorcraft_doc::text::ParaList::marker_style`], and a tab after them takes the text to
|
||||
/// the next tab stop past them on the first line, the left indent counting as a stop when no
|
||||
/// explicit stop comes before it. In a horizontal right-to-left paragraph, whose indents are
|
||||
/// mirrored ([`line_indents`]), these are measured from the right, and a number reads left to
|
||||
/// right with its period at its left. The tab keeps that width however the line is aligned or
|
||||
/// justified. Each of these glyphs stands for no character of the text: length 0, at the
|
||||
/// paragraph's first byte, in its first character's run, at the paragraph's embedding level.
|
||||
/// the next tab stop past them on the first line, the left indent plus its list level's indent
|
||||
/// ([`ParaStyle::laid_out_left_indent`]) counting as a stop when no explicit stop comes before
|
||||
/// it. With the list's [`ListAlign::Right`] that tab goes before them instead, with the same
|
||||
/// width: the marker ends where the text starts, and the text starts where it does with
|
||||
/// [`ListAlign::Left`]. In a horizontal right-to-left paragraph,
|
||||
/// whose indents are mirrored ([`line_indents`]), these are measured from the right, and a
|
||||
/// number reads left to right with its period at its left. The tab keeps that width however
|
||||
/// the line is aligned or justified. Each of these glyphs stands for no character of the
|
||||
/// text: length 0, at the paragraph's first byte, in its first character's run, at the
|
||||
/// paragraph's embedding level.
|
||||
fn prepend_marker(&self, pr: &Range<usize>, para: &ParaStyle, text: Option<&str>, rtl: bool, sg: &mut Vec<SGlyph>) -> usize {
|
||||
let Some(text) = text.filter(|_| para.list.is_some() && !pr.is_empty()) else { return 0 };
|
||||
let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start));
|
||||
@@ -281,11 +285,16 @@ impl Ctx<'_> {
|
||||
tab.kern_shift = 0.0;
|
||||
tab.tcy = None;
|
||||
tab.vpal = None;
|
||||
let pen = para.left_indent + para.first_line_indent + marker.iter().map(|g| g.adv).sum::<f64>();
|
||||
let to_indent = para.left_indent - pen;
|
||||
let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < para.left_indent - EPS);
|
||||
let left = para.laid_out_left_indent();
|
||||
let pen = left + para.first_line_indent + marker.iter().map(|g| g.adv).sum::<f64>();
|
||||
let to_indent = left - pen;
|
||||
let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < left - EPS);
|
||||
tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 };
|
||||
marker.push(tab);
|
||||
if para.list.is_some_and(|l| l.align == ListAlign::Right) {
|
||||
marker.insert(0, tab);
|
||||
} else {
|
||||
marker.push(tab);
|
||||
}
|
||||
let n = marker.len();
|
||||
sg.splice(0..0, marker);
|
||||
n
|
||||
|
||||
@@ -192,7 +192,7 @@ pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64,
|
||||
|
||||
/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the
|
||||
/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their
|
||||
/// glyph. The tab after a list marker (length 0) keeps its own width.
|
||||
/// glyph. The tab of a list marker (length 0) keeps its own width.
|
||||
pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
|
||||
let mut x = x_start;
|
||||
for (j, g) in sg.iter().enumerate().take(to).skip(from) {
|
||||
@@ -313,7 +313,8 @@ pub(super) fn break_line(
|
||||
}
|
||||
}
|
||||
}
|
||||
let cluster_width: f64 = if gl.ch == '\t' {
|
||||
// A list marker's glyphs, its tab included, keep their own widths.
|
||||
let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 {
|
||||
tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0
|
||||
} else {
|
||||
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
|
||||
@@ -348,14 +349,14 @@ pub(super) fn break_line(
|
||||
if j >= g.len() {
|
||||
return (g.len(), false);
|
||||
}
|
||||
// Hyphenate the word that overflows.
|
||||
let word_start = last_break.map_or(i, |b| b.0);
|
||||
// Hyphenate the word that overflows. A list marker starting the line is not part of a word.
|
||||
let word_start = last_break.map_or(first, |b| b.0);
|
||||
let in_zone = |zone: f64| {
|
||||
let mut te = word_start;
|
||||
while te > i && g[te - 1].is_space() {
|
||||
while te > first && g[te - 1].is_space() {
|
||||
te -= 1;
|
||||
}
|
||||
te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS
|
||||
te > first && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS
|
||||
};
|
||||
if let Some((opts, zone)) = hyphenate
|
||||
&& g[j].is_letter()
|
||||
@@ -555,7 +556,9 @@ pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&Hyphenatio
|
||||
let mut v = vec![];
|
||||
let mut ws = 0;
|
||||
for (j, gl) in g.iter().enumerate() {
|
||||
let end_of_word = gl.is_space() || gl.break_after();
|
||||
// A list marker (the glyphs of length 0 here) ends before the first word.
|
||||
let marker_end = gl.len == 0 && g.get(j + 1).is_some_and(|n| n.len > 0);
|
||||
let end_of_word = gl.is_space() || gl.break_after() || marker_end;
|
||||
if end_of_word {
|
||||
if let Some(opts) = hyphenate
|
||||
&& j > ws
|
||||
|
||||
@@ -34,7 +34,7 @@ fn compose_para(
|
||||
let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs);
|
||||
// A tab's width depends on where it falls in its line, which the composer's fixed glyph widths
|
||||
// can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs).
|
||||
// The tab after a list marker always starts the first line and has a fixed width.
|
||||
// The tab of a list marker is on the first line, with the marker, and has a fixed width.
|
||||
let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0);
|
||||
let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}');
|
||||
if composer != Composer::EveryLine
|
||||
@@ -66,7 +66,7 @@ fn compose_para(
|
||||
// indents of a right-to-left list ([`line_indents`]) keep.
|
||||
while widths.len() < sg.len() {
|
||||
let first = widths.is_empty();
|
||||
let ind_l = para.left_indent + if first && !continued { para.first_line_indent } else { 0.0 };
|
||||
let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 };
|
||||
// Only the first line's first word is known here. Later lines are placed as the composed
|
||||
// lines need: each fits its width, so its first word does too.
|
||||
let word = if first { word_width(sg, 0) } else { 0.0 };
|
||||
@@ -104,11 +104,11 @@ fn compose_para(
|
||||
}
|
||||
|
||||
/// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers
|
||||
/// counted through consecutive list paragraphs from [`TextObject::list_count`], as
|
||||
/// counted per level through consecutive list paragraphs from [`TextObject::list_counts`], as
|
||||
/// [`vectorcraft_doc::text::ParaList::next_marker`] counts them.
|
||||
pub(super) fn list_markers(t: &TextObject, paras: &[Range<usize>]) -> Vec<Option<String>> {
|
||||
let mut count = t.list_count;
|
||||
paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut count)).collect()
|
||||
let mut counts = t.list_counts();
|
||||
paras.iter().enumerate().map(|(pi, pr)| vectorcraft_doc::text::ParaList::next_marker(t.para_at(pi).list, pr.is_empty(), &mut counts)).collect()
|
||||
}
|
||||
|
||||
/// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal
|
||||
@@ -120,9 +120,10 @@ fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool {
|
||||
/// The indents at the left and at the right of a line of paragraph `para` (its first line when
|
||||
/// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from
|
||||
/// the right and the right indent from the left: the bullet of a right-to-left list hangs at the
|
||||
/// right, and the lines it wraps to line up with the text after the bullet.
|
||||
/// right, and the lines it wraps to line up with the text after the bullet. A list level past the
|
||||
/// first adds to the left indent ([`ParaStyle::laid_out_left_indent`]), mirrored with it.
|
||||
fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) {
|
||||
let start = para.left_indent + if first { para.first_line_indent } else { 0.0 };
|
||||
let start = para.laid_out_left_indent() + if first { para.first_line_indent } else { 0.0 };
|
||||
if mirror { (para.right_indent, start) } else { (start, para.right_indent) }
|
||||
}
|
||||
|
||||
@@ -723,7 +724,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
|
||||
let mut scale = 1.0;
|
||||
let mut leader = "";
|
||||
if glyph.ch == '\t' {
|
||||
// The tab after a list marker keeps the width `prepend_marker` gave it.
|
||||
// The tab of a list marker keeps the width `prepend_marker` gave it.
|
||||
if glyph.len > 0 {
|
||||
(advance, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[glyph_index + 1..trimmed.max(glyph_index + 1)]);
|
||||
}
|
||||
@@ -764,7 +765,7 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
|
||||
if glyph_index < paragraph.marker {
|
||||
let at = cx.out.glyphs.len();
|
||||
marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |range: Range<usize>| range.start.min(at)..range.end.max(at + 1)));
|
||||
if glyph_index + 1 == paragraph.marker {
|
||||
if glyph.ch == '\t' {
|
||||
marker_tab = at;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,8 @@ pub struct ListMarker {
|
||||
/// takes the text to the next tab stop.
|
||||
pub glyphs: std::ops::Range<usize>,
|
||||
/// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a
|
||||
/// right-to-left paragraph.
|
||||
/// right-to-left paragraph. A marker aligned right
|
||||
/// ([`vectorcraft_doc::text::ListAlign::Right`]) has it the other way round.
|
||||
pub tab: usize,
|
||||
/// The marker's characters ("•").
|
||||
pub text: String,
|
||||
|
||||
@@ -1355,6 +1355,157 @@ fn a_right_to_left_numbered_paragraph_has_its_period_left_of_its_number() {
|
||||
assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the number ends the line at the right: {last:?}");
|
||||
}
|
||||
|
||||
/// Bullets and Numbering › Alignment: Left puts a list paragraph's marker at the first line's
|
||||
/// start, and Right puts it against the text, which starts at the same place with either. Numbers
|
||||
/// of different widths aligned right end at the same place, the caret before the first character
|
||||
/// stands at the text, and the lines the paragraph wraps to start at the left indent. A number
|
||||
/// wider than the room before the left indent takes the text to the next tab stop and ends there.
|
||||
/// In a right-to-left paragraph, a marker aligned right starts where the text ends.
|
||||
#[test]
|
||||
fn a_list_marker_aligned_right_ends_where_the_text_starts() {
|
||||
use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};
|
||||
// Each marker's characters' left and right edges, and where its line's text starts.
|
||||
let spans = |t: &TextObject| -> Vec<(f64, f64, f64)> {
|
||||
let lay = layout(db(), t);
|
||||
let mut out = vec![];
|
||||
for m in &lay.markers {
|
||||
let chars: Vec<usize> = m.glyphs.clone().filter(|&i| i != m.tab).collect();
|
||||
let left = chars.iter().map(|&i| lay.glyphs[i].origin.x).fold(f64::MAX, f64::min);
|
||||
let right = chars.iter().map(|&i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).fold(f64::MIN, f64::max);
|
||||
let line = &lay.lines[lay.glyphs[m.tab].line];
|
||||
let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap();
|
||||
out.push((left, right, text.origin.x));
|
||||
}
|
||||
out
|
||||
};
|
||||
let numbered = |align: ListAlign, count: u32| {
|
||||
let mut t = listed("One\nTwo", &[Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); 2]);
|
||||
t.list_count = count;
|
||||
t
|
||||
};
|
||||
// "9." and "10.": aligned left, both start at the first-line indent (0); aligned right, both end
|
||||
// at the left indent (18), where the text starts either way.
|
||||
let left = spans(&numbered(ListAlign::Left, 8));
|
||||
let right = spans(&numbered(ListAlign::Right, 8));
|
||||
assert_eq!((left.len(), right.len()), (2, 2));
|
||||
for (k, ((l0, r0, text0), (l, r, text))) in left.iter().zip(&right).enumerate() {
|
||||
assert!(l0.abs() < 1e-6 && (text0 - 18.0).abs() < 1e-6, "item {k} aligned left: {l0} {text0}");
|
||||
assert!((r - 18.0).abs() < 1e-6 && (text - text0).abs() < 1e-6, "item {k} aligned right ends at the text: {r} {text}");
|
||||
assert!(((r - l) - (r0 - l0)).abs() < 1e-6, "item {k} keeps its width");
|
||||
}
|
||||
assert!(right[1].0 < right[0].0, "the wider number starts further left: {right:?}");
|
||||
let t = numbered(ListAlign::Right, 0);
|
||||
let lay = layout(db(), &t);
|
||||
let m = &lay.markers[0];
|
||||
assert_eq!((m.tab, lay.marker_text(m.tab), lay.marker_text(m.glyphs.start + 1)), (m.glyphs.start, Some("\t"), Some("1.")));
|
||||
let (top, _) = caret_position(&lay, 0);
|
||||
assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}");
|
||||
// A wrapped paragraph's other lines start at the left indent.
|
||||
let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))]);
|
||||
let lay = layout(db(), &long);
|
||||
assert!(lay.lines.len() > 2, "the paragraph wraps");
|
||||
for (i, line) in lay.lines.iter().enumerate() {
|
||||
let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap();
|
||||
assert!((first.origin.x - 18.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x);
|
||||
}
|
||||
let bullet = &spans(&long)[0];
|
||||
assert!((bullet.1 - 18.0).abs() < 1e-6 && bullet.0 > 0.0, "{bullet:?}");
|
||||
// "1000." is wider than the 18 pt before the left indent: the text and the number's end go to
|
||||
// the next default stop.
|
||||
let mut wide = listed("One", &[Some(ParaList::numbered(NumberStyle::ThreeDigits).aligned(ListAlign::Right))]);
|
||||
wide.list_count = 999;
|
||||
let (_, end, text) = spans(&wide)[0];
|
||||
assert!((text - 36.0).abs() < 1e-6 && (end - 36.0).abs() < 1e-6, "{end} {text}");
|
||||
// Right to left: aligned left the bullet ends the line at the right; aligned right it starts
|
||||
// where the text ends, and the text ends where it does aligned left.
|
||||
let rtl = |align: ListAlign| {
|
||||
let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).aligned(align))]);
|
||||
t.edit_paras(None, |p| p.justify = Justify::Auto);
|
||||
let lay = layout(db(), &t);
|
||||
let m = &lay.markers[0];
|
||||
let bullet = m.glyphs.clone().find(|&i| i != m.tab).map(|i| (lay.glyphs[i].origin.x, lay.glyphs[i].origin.x + lay.glyphs[i].advance));
|
||||
let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(0.0, f64::max);
|
||||
(bullet.unwrap(), text_right)
|
||||
};
|
||||
let ((_, end), text_left_aligned) = rtl(ListAlign::Left);
|
||||
assert!((end - 200.0).abs() < 1e-6, "{end}");
|
||||
let ((start, _), text_right_aligned) = rtl(ListAlign::Right);
|
||||
assert!((start - text_right_aligned).abs() < 1e-6 && (text_right_aligned - text_left_aligned).abs() < 1e-6, "{start} {text_right_aligned}");
|
||||
}
|
||||
|
||||
/// A list paragraph breaks its lines where the same paragraph without a marker, its text at the
|
||||
/// same indent, breaks them: with either marker alignment, a bullet or a letter, and either
|
||||
/// composer, the marker and its gap count at their own width and the first word hyphenates.
|
||||
#[test]
|
||||
fn a_list_paragraph_breaks_its_lines_as_its_text_alone_does() {
|
||||
use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};
|
||||
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
|
||||
// Each line's end, and whether it ends with a hyphen.
|
||||
let breaks = |list: Option<ParaList>, composer: vectorcraft_doc::Composer| {
|
||||
let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 98.0, 600.0), Justify::Left);
|
||||
t.para.left_indent = 18.0;
|
||||
t.para.first_line_indent = if list.is_some() { -18.0 } else { 0.0 };
|
||||
t.para.hyphenate = true;
|
||||
t.para.composer = composer;
|
||||
t.para.list = list;
|
||||
let lay = layout(db(), &t);
|
||||
let hyphen = |k: usize| lay.glyphs[k].len == 0 && lay.marker_of(k).is_none();
|
||||
lay.lines.iter().map(|l| (l.end, (l.glyph_start..l.glyph_end).any(hyphen))).collect::<Vec<_>>()
|
||||
};
|
||||
for composer in [vectorcraft_doc::Composer::SingleLine, vectorcraft_doc::Composer::EveryLine] {
|
||||
let alone = breaks(None, composer);
|
||||
assert!(alone.first().is_some_and(|&(_, hyphen)| hyphen), "the first word hyphenates: {alone:?}");
|
||||
for list in [ParaList::bulleted(Bullet::Dot), ParaList::numbered(NumberStyle::UpperLetters)] {
|
||||
for align in [ListAlign::Left, ListAlign::Right] {
|
||||
assert_eq!(breaks(Some(list.aligned(align)), composer), alone, "{composer:?} {list:?} {align:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List levels: a paragraph at a level past the first is set 18 pt further in for each level, its
|
||||
/// marker, its text and the lines it wraps to; each level counts its own numbers, a deeper level
|
||||
/// starting at 1 and a shallower one going on with its count, from the counts the earlier frames of
|
||||
/// a thread carry. A right-to-left list paragraph is set that much further in from the right.
|
||||
#[test]
|
||||
fn a_list_paragraph_at_a_deeper_level_is_set_further_in_and_counts_on_its_own() {
|
||||
use vectorcraft_doc::text::{Bullet, ListCounts, NumberStyle, ParaList};
|
||||
let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
|
||||
let t = listed("One\nTwo\nThree\nFour\nFive\nSix\nSeven", &[num(1), num(2), num(2), num(3), num(2), num(1), num(2)]);
|
||||
assert_eq!(marker_texts(&t), ["1.", "1.", "2.", "1.", "3.", "2.", "1."]);
|
||||
let lay = layout(db(), &t);
|
||||
for (k, (m, level)) in lay.markers.iter().zip([1, 2, 2, 3, 2, 1, 2]).enumerate() {
|
||||
let number = &lay.glyphs[m.glyphs.start];
|
||||
let line = &lay.lines[number.line];
|
||||
let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap();
|
||||
let step = 18.0 * f64::from(level - 1);
|
||||
assert!((number.origin.x - step).abs() < 1e-6, "item {k}: the number starts at {}", number.origin.x);
|
||||
assert!((text.origin.x - 18.0 - step).abs() < 1e-6, "item {k}: the text starts at {}", text.origin.x);
|
||||
}
|
||||
// The counts the earlier frames carry, per level.
|
||||
let mut t = listed("Two\nThree\nFour", &[num(2), num(3), num(1)]);
|
||||
t.set_list_counts(ListCounts([4, 6, 0, 0, 0, 0, 0, 0, 0]));
|
||||
assert_eq!(marker_texts(&t), ["7.", "1.", "5."]);
|
||||
// The lines a deeper paragraph wraps to start at its left indent plus the level's 18 pt.
|
||||
let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).at_level(3))]);
|
||||
let lay = layout(db(), &long);
|
||||
assert!(lay.lines.len() > 2, "the paragraph wraps");
|
||||
assert!((lay.glyphs[lay.markers[0].glyphs.start].origin.x - 36.0).abs() < 1e-6);
|
||||
for (i, line) in lay.lines.iter().enumerate() {
|
||||
let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap();
|
||||
assert!((first.origin.x - 54.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x);
|
||||
}
|
||||
// Right to left: the bullet at level 2 ends 18 pt before the right edge.
|
||||
let rtl = |level| {
|
||||
let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).at_level(level))]);
|
||||
t.edit_paras(None, |p| p.justify = Justify::Auto);
|
||||
let lay = layout(db(), &t);
|
||||
let m = &lay.markers[0];
|
||||
m.glyphs.clone().find(|&i| i != m.tab).map(|i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).unwrap()
|
||||
};
|
||||
assert!((rtl(1) - 200.0).abs() < 1e-6 && (rtl(2) - 182.0).abs() < 1e-6, "{} {}", rtl(1), rtl(2));
|
||||
}
|
||||
|
||||
/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when
|
||||
/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts.
|
||||
fn japanese_fonts() -> bool {
|
||||
|
||||
+40
-14
@@ -4,6 +4,7 @@
|
||||
//! testing need nothing special; after an edit the engine joins the slices and re-distributes them
|
||||
//! with [`distribute`].
|
||||
|
||||
use vectorcraft_doc::text::ListCounts;
|
||||
use vectorcraft_doc::{ParaStyle, TextObject, TextRun};
|
||||
|
||||
use crate::edit::{normalize, runs_len, slice_runs, style_at};
|
||||
@@ -82,29 +83,38 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
}
|
||||
|
||||
/// A frame's share of a story: its runs, its paragraphs' styles (one per paragraph) and the
|
||||
/// numbered list paragraphs counted before its first one ([`TextObject::list_count`]).
|
||||
/// numbered list paragraphs counted before its first one, at level 1 and at the levels past it
|
||||
/// ([`TextObject::list_count`], [`TextObject::list_level_counts`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slice {
|
||||
pub runs: Vec<TextRun>,
|
||||
pub paras: Vec<ParaStyle>,
|
||||
pub list_count: u32,
|
||||
pub list_level_counts: Vec<u32>,
|
||||
/// The first paragraph continues from the frame before
|
||||
/// ([`TextObject::first_para_continues`]).
|
||||
pub continues: bool,
|
||||
}
|
||||
|
||||
/// The count of numbered list paragraphs after the paragraphs of `runs` (styles `paras`, one per
|
||||
/// paragraph) but the last one, counted on from `count` as the layout counts them
|
||||
impl Slice {
|
||||
/// The list counts the slice's frame starts from ([`TextObject::list_counts`]).
|
||||
pub fn list_counts(&self) -> ListCounts {
|
||||
ListCounts::from_parts(self.list_count, &self.list_level_counts)
|
||||
}
|
||||
}
|
||||
|
||||
/// The counts of numbered list paragraphs at each level after the paragraphs of `runs` (styles
|
||||
/// `paras`, one per paragraph) but the last one, counted on from `counts` as the layout counts them
|
||||
/// ([`vectorcraft_doc::text::ParaList::next_marker`]). A slice's last paragraph also starts the
|
||||
/// next frame, which counts it.
|
||||
fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut count: u32) -> u32 {
|
||||
fn count_list_paragraphs(runs: &[TextRun], paras: &[ParaStyle], mut counts: ListCounts) -> ListCounts {
|
||||
let text: String = runs.iter().map(|r| r.text.as_str()).collect();
|
||||
let mut pieces: Vec<&str> = text.split('\n').collect();
|
||||
pieces.pop();
|
||||
for (piece, para) in pieces.iter().zip(paras) {
|
||||
vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut count);
|
||||
vectorcraft_doc::text::ParaList::next_marker(para.list, piece.is_empty(), &mut counts);
|
||||
}
|
||||
count
|
||||
counts
|
||||
}
|
||||
|
||||
/// Whether the frame after one whose share ends with byte `before` starts inside a paragraph: it
|
||||
@@ -134,21 +144,36 @@ pub fn continued(frames: &[&TextObject]) -> Vec<bool> {
|
||||
/// keep one empty run carrying the style at the split, so typing there has a style. A paragraph
|
||||
/// split between frames has its style in both, and each slice that starts inside a paragraph is
|
||||
/// marked as continuing it ([`Slice::continues`]). Each frame's numbered list paragraphs are counted
|
||||
/// on from the frames before it. Each frame's share is measured with that mark and that count.
|
||||
/// on from the frames before it, at each level. Each frame's share is measured with that mark and
|
||||
/// those counts.
|
||||
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec<Slice> {
|
||||
distribute_continuing(db, frames, runs, paras, false, 0)
|
||||
distribute_continuing_levels(db, frames, runs, paras, false, ListCounts::default())
|
||||
}
|
||||
|
||||
/// [`distribute`] for `runs` that follow text before them: the first frame's share is measured,
|
||||
/// and marked, as continuing a paragraph when `continued` is set, and its numbered list paragraphs
|
||||
/// are counted on from `list_count`.
|
||||
/// at level 1 are counted on from `list_count` ([`distribute_continuing_levels`] takes the counts
|
||||
/// of every level).
|
||||
pub fn distribute_continuing(
|
||||
db: &FontDb,
|
||||
frames: &[&TextObject],
|
||||
runs: &[TextRun],
|
||||
paras: &[ParaStyle],
|
||||
continued: bool,
|
||||
mut list_count: u32,
|
||||
list_count: u32,
|
||||
) -> Vec<Slice> {
|
||||
distribute_continuing_levels(db, frames, runs, paras, continued, ListCounts::from_parts(list_count, &[]))
|
||||
}
|
||||
|
||||
/// [`distribute_continuing`] with the first frame's numbered list paragraphs counted on from
|
||||
/// `counts` at each level.
|
||||
pub fn distribute_continuing_levels(
|
||||
db: &FontDb,
|
||||
frames: &[&TextObject],
|
||||
runs: &[TextRun],
|
||||
paras: &[ParaStyle],
|
||||
continued: bool,
|
||||
mut counts: ListCounts,
|
||||
) -> Vec<Slice> {
|
||||
let mut rest = runs.to_vec();
|
||||
let mut out = Vec::with_capacity(frames.len());
|
||||
@@ -165,7 +190,7 @@ pub fn distribute_continuing(
|
||||
let mut probe = (*f).clone();
|
||||
probe.runs = rest.clone();
|
||||
probe.set_paragraph_styles(slice_paras(paras, first_para, &rest));
|
||||
probe.list_count = list_count;
|
||||
probe.set_list_counts(counts);
|
||||
probe.first_para_continues = continues;
|
||||
fit(db, &probe, &rest)
|
||||
};
|
||||
@@ -177,11 +202,12 @@ pub fn distribute_continuing(
|
||||
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len), inline: None }] } else { tail };
|
||||
let head_paras = slice_paras(paras, first_para, &head);
|
||||
next_para += head_paras.len().saturating_sub(1);
|
||||
let next_count = count_list_paragraphs(&head, &head_paras, list_count);
|
||||
let next_counts = count_list_paragraphs(&head, &head_paras, counts);
|
||||
let starts_continued = continues;
|
||||
continues = continues_after(head.iter().rev().find_map(|r| r.text.bytes().last()), continues);
|
||||
out.push(Slice { runs: head, paras: head_paras, list_count, continues: starts_continued });
|
||||
list_count = next_count;
|
||||
let (list_count, list_level_counts) = counts.parts();
|
||||
out.push(Slice { runs: head, paras: head_paras, list_count, list_level_counts, continues: starts_continued });
|
||||
counts = next_counts;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -378,6 +378,40 @@ fn a_list_paragraph_continued_onto_a_path_has_no_bullet_there() {
|
||||
assert_eq!(layout(db, &alone).markers.len(), 1);
|
||||
}
|
||||
|
||||
/// List levels: each level's count runs on through the frames, a deeper level in the next
|
||||
/// frame going on with the count its paragraphs reached in the frame before.
|
||||
#[test]
|
||||
fn list_levels_count_on_through_the_frames() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let db = FontDb::global();
|
||||
let (a, mut b) = (frame(0.0), frame(200.0));
|
||||
b.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(200.0, 0.0, 320.0, 400.0)) };
|
||||
let at = |level| ParaStyle {
|
||||
list: Some(ParaList::numbered(NumberStyle::Arabic).at_level(level)),
|
||||
left_indent: 18.0,
|
||||
first_line_indent: -18.0,
|
||||
..Default::default()
|
||||
};
|
||||
let numbers = |f: &TextObject, s: &Slice| {
|
||||
let mut t = f.clone();
|
||||
t.runs = s.runs.clone();
|
||||
t.set_paragraph_styles(s.paras.clone());
|
||||
t.set_list_counts(s.list_counts());
|
||||
layout(db, &t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
// One item at level 1 and eleven at level 2: 1. then 1. to 11. through both frames.
|
||||
let paras: Vec<ParaStyle> = (0..12).map(|i| at(if i == 0 { 1 } else { 2 })).collect();
|
||||
let text = (1..=12).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let story = vec![TextRun { text, style: CharStyle::default(), inline: None }];
|
||||
let parts = distribute(db, &[&a, &b], &story, ¶s);
|
||||
let first = numbers(&a, &parts[0]);
|
||||
assert!((2..12).contains(&first.len()), "both frames hold items: {first:?}");
|
||||
let both: Vec<String> = [first.clone(), numbers(&b, &parts[1])].concat();
|
||||
let expected: Vec<String> = std::iter::once("1.".to_string()).chain((1..=11).map(|n| format!("{n}."))).collect();
|
||||
assert_eq!(both, expected);
|
||||
assert_eq!((parts[1].list_count, parts[1].list_level_counts.clone()), (1, vec![first.len() as u32 - 1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_story_leaves_later_frames_empty_with_a_style() {
|
||||
let db = FontDb::global();
|
||||
|
||||
@@ -476,6 +476,43 @@ impl TypeTool {
|
||||
out
|
||||
}
|
||||
|
||||
/// Tab (`by` 1) or Shift+Tab (`by` -1) in type `t` (plain text `text`) with the selection
|
||||
/// `a..b`: with the caret at the start of a list paragraph, the `text.setFormat` that moves the
|
||||
/// paragraph a level down or up (none at the first or the last level: the key then does
|
||||
/// nothing). None when the caret is anywhere else, the selection holds characters, or the
|
||||
/// paragraph is in no list. The start of a threaded frame whose first paragraph continues from
|
||||
/// the frame before ([`TextObject::first_para_continues`]) is not the start of a paragraph.
|
||||
fn list_level_step(&self, t: &TextObject, text: &str, a: usize, b: usize, by: i32) -> Option<Vec<Action>> {
|
||||
let id = self.editing?;
|
||||
if a != b || edit::paragraph_at(text, a).start != a || (a == 0 && t.first_para_continues) {
|
||||
return None;
|
||||
}
|
||||
let para = text.get(..a)?.matches('\n').count();
|
||||
let list = t.para_at(para).list?;
|
||||
let level = i32::try_from(list.depth()).ok()? + by;
|
||||
let max = i32::try_from(vectorcraft_doc::text::LIST_LEVELS).ok()?;
|
||||
if !(1..=max).contains(&level) {
|
||||
return Some(vec![]);
|
||||
}
|
||||
Some(vec![Action::Exec("text.setFormat".into(), json!({"id": id.0, "start": a, "end": a, "listLevel": level}))])
|
||||
}
|
||||
|
||||
/// Tab (Shift+Tab with `shift`) in type `t` (plain text `text`) with the selection `a..b`: with
|
||||
/// the caret at the start of a list paragraph, it moves the paragraph a level down or up
|
||||
/// ([`Self::list_level_step`]); anywhere else Tab inserts a tab.
|
||||
fn tab(&mut self, cx: &ToolContext, t: &TextObject, text: &str, (a, b): (usize, usize), shift: bool) -> Vec<Action> {
|
||||
match self.list_level_step(t, text, a, b, if shift { -1 } else { 1 }) {
|
||||
// At the first or the last level the key does nothing, and the typing goes on.
|
||||
Some(step) if step.is_empty() => step,
|
||||
Some(step) => {
|
||||
let mut out = self.commit();
|
||||
out.extend(step);
|
||||
out
|
||||
}
|
||||
None => self.replace(cx, "\t", None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The other frames whose whole text is selected along with the edited frame's after Select
|
||||
/// All in threaded text ([`Self::story`]), read from the document's current thread: none once
|
||||
/// the selection is not all of the edited frame's text.
|
||||
|
||||
@@ -535,6 +535,105 @@ fn backspace_deletes_selection_or_word() {
|
||||
assert_eq!(tool.caret, 6);
|
||||
}
|
||||
|
||||
/// Tab and Shift+Tab with the caret at the start of a list paragraph move it a level down or up
|
||||
/// with one `text.setFormat` (one undo step), ending the typing first; at level 1 Shift+Tab and at
|
||||
/// level 9 Tab do nothing. Anywhere else Tab inserts a tab: in the middle or at the end of a list
|
||||
/// paragraph, with characters selected, and in a paragraph in no list.
|
||||
#[test]
|
||||
fn tab_at_the_start_of_a_list_paragraph_changes_its_level() {
|
||||
use vectorcraft_doc::text::{Bullet, ParaList};
|
||||
let (mut d, id, mut tool) = editing("One\nTwo\nThree");
|
||||
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") };
|
||||
let dot = |level| vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot).at_level(level)), ..Default::default() };
|
||||
t.set_paragraph_styles(vec![dot(1), dot(9), vectorcraft_doc::ParaStyle::default()]);
|
||||
let press = |tool: &mut TypeTool, at: (usize, usize), mods: Mods| {
|
||||
(tool.anchor, tool.caret) = at;
|
||||
key(tool, &d, ToolKey::Tab, mods)
|
||||
};
|
||||
let level = |acts: &[Action]| match acts {
|
||||
[Action::Exec(cmd, p)] if cmd == "text.setFormat" => {
|
||||
assert_eq!((p["id"].as_u64(), p["end"].as_u64()), (Some(id.0), p["start"].as_u64()));
|
||||
(p["start"].as_u64(), p["listLevel"].as_i64())
|
||||
}
|
||||
other => panic!("not a level change: {other:?}"),
|
||||
};
|
||||
assert_eq!(level(&press(&mut tool, (0, 0), Mods::default())), (Some(0), Some(2)));
|
||||
assert_eq!(level(&press(&mut tool, (4, 4), SHIFT)), (Some(4), Some(8)));
|
||||
assert!(press(&mut tool, (0, 0), SHIFT).is_empty(), "Shift+Tab at level 1 does nothing");
|
||||
assert!(press(&mut tool, (4, 4), Mods::default()).is_empty(), "Tab at level 9 does nothing");
|
||||
let inserts_tab = |acts: &[Action], at: u64| {
|
||||
acts.iter().any(
|
||||
|a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["start"].as_u64() == Some(at) && p["runs"][0]["text"] == "\t"),
|
||||
)
|
||||
};
|
||||
for (at, why) in [((1, 1), "mid-item"), ((3, 3), "at the item's end"), ((0, 2), "with characters selected"), ((8, 8), "in no list")] {
|
||||
let mut fresh = TypeTool::new("type");
|
||||
fresh.start_editing(id, 0);
|
||||
let acts = press(&mut fresh, at, Mods::default());
|
||||
assert!(inserts_tab(&acts, at.0 as u64), "{why}: {acts:?}");
|
||||
}
|
||||
// While typing, Tab at the start of a list paragraph ends the typing first.
|
||||
let mut typing = TypeTool::new("type");
|
||||
typing.start_editing(id, 3);
|
||||
let snapshot = d.clone();
|
||||
let mut after = d.clone();
|
||||
let acts = typing.text_input(&cx(&d, &Selection::default(), &paint()), "!");
|
||||
apply(&mut after, &snapshot, &acts);
|
||||
// Tab at level 9 does nothing, and the typing goes on as one undo step.
|
||||
(typing.anchor, typing.caret) = (5, 5);
|
||||
let acts = typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, Mods::default());
|
||||
assert!(acts.is_empty() && typing.typing.is_some(), "Tab at level 9 while typing: {acts:?}");
|
||||
let acts = {
|
||||
(typing.anchor, typing.caret) = (5, 5);
|
||||
typing.key(&cx(&after, &Selection::default(), &paint()), ToolKey::Tab, SHIFT)
|
||||
};
|
||||
assert_eq!(acts.first(), Some(&Action::Commit));
|
||||
assert!(matches!(&acts[1..], [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 8 && p["start"] == 5), "{acts:?}");
|
||||
}
|
||||
|
||||
/// In a threaded frame, the start of the frame is the start of a list paragraph only when the
|
||||
/// frames before it end with a paragraph break: Tab there in a paragraph that goes on from the
|
||||
/// frame before inserts a tab, and Tab after the paragraph's end changes the next one's level.
|
||||
#[test]
|
||||
fn tab_at_the_start_of_a_threaded_frame_changes_a_level_only_at_a_paragraph_start() {
|
||||
use vectorcraft_doc::text::{Bullet, ParaList};
|
||||
let dot = vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() };
|
||||
let threaded = |first: &str, second: &str| {
|
||||
let (mut d, a) = doc_with_text(first);
|
||||
let l = d.layers[0].id;
|
||||
let b = d.alloc_id();
|
||||
let mut t = TextObject::point(Point::new(100.0, 300.0), second, CharStyle { size: 20.0, ..Default::default() });
|
||||
t.set_paragraph_styles(vec![dot.clone(); second.matches('\n').count() + 1]);
|
||||
let NodeKind::Text(head) = &d.node(a).unwrap().kind else { panic!("text") };
|
||||
t.first_para_continues = vectorcraft_text::thread::continued(&[head, &t])[1];
|
||||
d.insert(Some(l), 0, Node::new(b, NodeKind::Text(Box::new(t)))).unwrap();
|
||||
let NodeKind::Text(t) = &mut d.node_mut(a).unwrap().kind else { panic!("text") };
|
||||
t.set_paragraph_styles(vec![dot.clone(); first.matches('\n').count() + 1]);
|
||||
d.text_threads.push(vec![a, b]);
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.start_editing(b, 0);
|
||||
(d, b, tool)
|
||||
};
|
||||
let level_change = |acts: &[Action]| matches!(acts, [Action::Exec(cmd, p)] if cmd == "text.setFormat" && p["listLevel"] == 2);
|
||||
let inserts_tab =
|
||||
|acts: &[Action]| acts.iter().any(|a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"] == "\t"));
|
||||
// "Two" goes on from the first frame: Tab at the frame's start inserts a tab, and Tab at the
|
||||
// start of "Three" changes its level.
|
||||
let (d, _, mut tool) = threaded("One\nTw", "o\nThree");
|
||||
let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default());
|
||||
assert!(inserts_tab(&acts), "mid-paragraph: {acts:?}");
|
||||
let (d, _, mut tool) = threaded("One\nTw", "o\nThree");
|
||||
(tool.anchor, tool.caret) = (2, 2);
|
||||
let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default());
|
||||
assert!(level_change(&acts), "at the next paragraph: {acts:?}");
|
||||
// A frame after a paragraph break, or after frames with no text, starts a paragraph.
|
||||
for first in ["One\n", ""] {
|
||||
let (d, _, mut tool) = threaded(first, "Two");
|
||||
let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default());
|
||||
assert!(level_change(&acts), "after {first:?}: {acts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_and_triple_click_select_word_and_paragraph() {
|
||||
let (d, id, mut tool) = editing("Alpha beta\ngamma");
|
||||
@@ -1740,3 +1839,31 @@ fn shift_enter_inserts_soft_return() {
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "Hello\u{2028}\n World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_return_in_a_list_item_keeps_tab_within_the_paragraph() {
|
||||
use vectorcraft_doc::text::{Bullet, ParaList};
|
||||
|
||||
let (mut d, id, mut tool) = editing("One");
|
||||
let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!("text") };
|
||||
t.set_all_paras(vectorcraft_doc::ParaStyle { list: Some(ParaList::bulleted(Bullet::Dot)), ..Default::default() });
|
||||
tool.caret = 3;
|
||||
tool.anchor = 3;
|
||||
let snapshot = d.clone();
|
||||
let acts = key(&mut tool, &d, ToolKey::Enter, SHIFT);
|
||||
apply(&mut d, &snapshot, &acts);
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.plain_text(), "One\u{2028}");
|
||||
assert_eq!(t.paragraph_count(), 1);
|
||||
assert_eq!(t.para_at(0).list, Some(ParaList::bulleted(Bullet::Dot)));
|
||||
|
||||
let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default());
|
||||
assert!(acts.iter().any(
|
||||
|a| matches!(a, Action::Preview(cmd, p) if cmd == "text.editRange" && p["runs"][0]["text"].as_str().is_some_and(|s| s.ends_with('\t')))
|
||||
));
|
||||
|
||||
tool.caret = 0;
|
||||
tool.anchor = 0;
|
||||
let acts = key(&mut tool, &d, ToolKey::Tab, Mods::default());
|
||||
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.setFormat" && p["listLevel"] == 2)));
|
||||
}
|
||||
|
||||
@@ -231,7 +231,8 @@ impl Tool for TypeTool {
|
||||
// tool shortcuts again (#1686).
|
||||
ToolKey::Escape => self.end_editing(cx),
|
||||
ToolKey::Enter => self.enter(cx, mods),
|
||||
ToolKey::Tab => self.replace(cx, "\t", None),
|
||||
ToolKey::Tab if story => self.replace(cx, "\t", None),
|
||||
ToolKey::Tab => self.tab(cx, &t, &text, (a, b), mods.shift),
|
||||
ToolKey::Backspace | ToolKey::Delete if a != b || story => self.replace(cx, "", None),
|
||||
ToolKey::Backspace if self.caret > 0 => {
|
||||
self.anchor = if word { edit::prev_word(&text, self.caret) } else { edit::prev_char(&text, self.caret) };
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod blend_options;
|
||||
mod brush_art_options;
|
||||
pub mod brush_delete;
|
||||
pub mod brush_options;
|
||||
pub mod bullets_numbering;
|
||||
pub mod char_style_options;
|
||||
pub mod color_balance;
|
||||
pub mod color_guide_options;
|
||||
@@ -348,6 +349,7 @@ registry! {
|
||||
Corners: [corners::KIND] => corners::SPEC,
|
||||
Hyphenation: [hyphenation::KIND] => hyphenation::SPEC,
|
||||
Justification: [justification::KIND] => justification::SPEC,
|
||||
BulletsAndNumbering: [bullets_numbering::KIND] => bullets_numbering::SPEC,
|
||||
TateChuYokoSettings: [tate_chu_yoko::KIND] => tate_chu_yoko::SPEC,
|
||||
CompositeFonts: [composite_fonts::KIND] => composite_fonts::SPEC,
|
||||
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
//! More Options at the end of the Paragraph panel's Bullets and Numbered List presets (or `ui.bulletsAndNumbering {tab?}`): a
|
||||
//! Bullets tab and a Numbers tab, each with the Level field and the presets, and the Position group
|
||||
//! (Alignment, Left Indent and First Line Indent) of the paragraphs the Paragraph panel acts on.
|
||||
//! The dialog opens on the values the Paragraph panel shows, with no preset picked and a field
|
||||
//! blank where the paragraphs differ. Each change shows on the type while the dialog is open. OK
|
||||
//! runs `text.setFormat` with the values that changed, and with the indents it shows when a preset
|
||||
//! puts the paragraphs in a list, as one undo step; Cancel puts the type back as it was. A preset
|
||||
//! picked for paragraphs that are in no list and have no indents fills in the 18 pt and -18 pt
|
||||
//! indents `text.setFormat` gives them, and None takes them out again.
|
||||
//!
|
||||
//! Fields: `tab` (`bullets` or `numbers`), `list` (`none`, `bullet` or `number`: the kind of the
|
||||
//! preset picked), `bullet` (`dot`, `dash`, `circle` or `square`), `number` (`arabic`,
|
||||
//! `upperLetters`, `lowerLetters`, `upperRoman`, `lowerRoman`, `twoDigits` or `threeDigits`),
|
||||
//! `listAlign` (`left` or `right`), `listLevel` (1 to 9; 1 for paragraphs in no list),
|
||||
//! `leftIndent` and `firstLineIndent` (pt), as `text.setFormat` takes them, with `__initial` (the
|
||||
//! values it opened with) and `__targets` (the type and range it acts on).
|
||||
//!
|
||||
//! Behavior reference (public documentation):
|
||||
//! <https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html>.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::text::{LIST_HANGING_INDENT, LIST_LEVELS, NumberStyle, ParaList};
|
||||
|
||||
use super::swatch_options::label;
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Bullets and Numbering.
|
||||
pub const KIND: &str = "bulletsAndNumbering";
|
||||
|
||||
/// The field holding the values the dialog opened with.
|
||||
const INITIAL: &str = "__initial";
|
||||
/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on.
|
||||
const TARGETS: &str = "__targets";
|
||||
/// Set while the indent fields hold the hanging indent a preset filled in ([`pick`]).
|
||||
const HANGING: &str = "__hanging";
|
||||
const CMD: &str = "text.setFormat";
|
||||
|
||||
/// The `tab` values, in the order the tabs are shown.
|
||||
const TABS: [&str; 2] = ["bullets", "numbers"];
|
||||
/// The `listAlign` values, in the order Alignment lists them.
|
||||
const ALIGNS: [&str; 2] = ["left", "right"];
|
||||
/// The indents `text.setFormat` keeps (points): a value typed outside them becomes the nearer end.
|
||||
const INDENTS: std::ops::RangeInclusive<f64> = -1296.0..=1296.0;
|
||||
/// The side of a preset button (points).
|
||||
const PRESET: f32 = 34.0;
|
||||
/// The keys a preset button with the keyboard focus keeps from egui's focus moves.
|
||||
const ARROWS: egui::EventFilter = egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: false, escape: false };
|
||||
|
||||
/// The fields `ui.dialog.set` can set while the dialog does not hold them: the dialog leaves a
|
||||
/// field out where the paragraphs differ, and holds no `bullet` or `number` for paragraphs in no
|
||||
/// list or in another kind of list.
|
||||
const INPUTS: [&str; 7] = ["list", "bullet", "number", "listAlign", "listLevel", "leftIndent", "firstLineIndent"];
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Bullets and Numbering").into(),
|
||||
body,
|
||||
confirm,
|
||||
preview: true,
|
||||
min_width: 380.0,
|
||||
inputs: |_| &INPUTS,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The kind of list `list` is, as the `list` field names it.
|
||||
fn kind(list: Option<ParaList>) -> &'static str {
|
||||
match list {
|
||||
None => "none",
|
||||
Some(l) if l.number.is_some() => "number",
|
||||
Some(_) => "bullet",
|
||||
}
|
||||
}
|
||||
|
||||
/// Open Bullets and Numbering on the paragraph attributes the Paragraph panel shows, on tab `tab`
|
||||
/// (`bullets` or `numbers`; without it, Numbers when the paragraphs are a numbered list).
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
use crate::panels::paragraph::{shared_para, shared_para_field};
|
||||
let key = "tab";
|
||||
let tab = match p.get(key) {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_str().filter(|t| TABS.contains(t)).ok_or_else(|| format!("`{key}` must be one of {}", TABS.join(", ")))?),
|
||||
};
|
||||
crate::panels::character::text_style(app).ok_or("select some type first")?;
|
||||
// While typing, the dialog acts on the paragraphs of the text being edited.
|
||||
let mut targets = json!({});
|
||||
if let Some((id, a, b)) = crate::panels::character::text_editing(app) {
|
||||
crate::panels::character::end_typing(app);
|
||||
targets = json!({"ids": [id.0], "start": a, "end": b});
|
||||
}
|
||||
let mut values = Map::new();
|
||||
let list = shared_para(app, |p| kind(p.list));
|
||||
if let Some(k) = list {
|
||||
values.insert("list".into(), json!(k));
|
||||
}
|
||||
if let Some(Some(b)) = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet)) {
|
||||
values.insert("bullet".into(), json!(b));
|
||||
}
|
||||
if let Some(Some(n)) = shared_para(app, |p| p.list.and_then(|l| l.number)) {
|
||||
values.insert("number".into(), json!(n));
|
||||
}
|
||||
// A paragraph in no list counts as aligned left, the alignment a list starts with.
|
||||
if let Some(a) = shared_para(app, |p| p.list.map(|l| l.align).unwrap_or_default()) {
|
||||
values.insert("listAlign".into(), json!(a));
|
||||
}
|
||||
// …and the paragraphs' list level, 1 for a paragraph in no list (the level a list starts at).
|
||||
if let Some(l) = shared_para(app, |p| p.list.map_or(1, |l| l.depth())) {
|
||||
values.insert("listLevel".into(), json!(l));
|
||||
}
|
||||
if let Some(v) = shared_para_field(app, |p| p.left_indent) {
|
||||
values.insert("leftIndent".into(), json!(v));
|
||||
}
|
||||
if let Some(v) = shared_para_field(app, |p| p.first_line_indent) {
|
||||
values.insert("firstLineIndent".into(), json!(v));
|
||||
}
|
||||
let tab = tab.unwrap_or(if list == Some("number") { "numbers" } else { "bullets" });
|
||||
let mut fields = Value::Object(values.clone());
|
||||
fields[INITIAL] = Value::Object(values);
|
||||
fields[TARGETS] = targets;
|
||||
fields["tab"] = json!(tab);
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let unit = app.session.general_unit();
|
||||
let numbers = d.str("tab") == "numbers";
|
||||
if let Some(tab) = widgets::tab_bar(ui, &[tl!("Bullets"), tl!("Numbers")], usize::from(numbers)).and_then(|i| TABS.get(i)) {
|
||||
d.fields.insert("tab".into(), json!(tab));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
level(ui, d);
|
||||
ui.add_space(8.0);
|
||||
if d.str("tab") == "numbers" {
|
||||
number_tab(ui, d);
|
||||
} else {
|
||||
bullet_tab(ui, d);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
position(ui, d, unit);
|
||||
// The type shows the dialog's values; with nothing changed it is as it was.
|
||||
let p = params(d);
|
||||
if changes(&p) {
|
||||
form::preview_now(app, d, tl!("Bullets and Numbering"), CMD, Value::Object(p));
|
||||
} else if d.fields.remove(form::PREVIEWED).is_some() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The Level field: the paragraphs' list level, 1 to 9, blank where they differ.
|
||||
fn level(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, tl!("Level:"));
|
||||
let value = d.fields.get("listLevel").and_then(Value::as_f64);
|
||||
if let Some(v) = widgets::range_field(ui, ("bullets-numbering", "listLevel"), value, 1.0..=LIST_LEVELS as f64, "", 0, 60.0) {
|
||||
d.fields.insert("listLevel".into(), json!(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Bullets tab: the bullet presets in a row, None first, the one the dialog applies selected.
|
||||
fn bullet_tab(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let presets = crate::panels::paragraph::bullet_presets();
|
||||
let shown = d.fields.get("bullet").and_then(Value::as_str);
|
||||
let current = match d.fields.get("list").and_then(Value::as_str) {
|
||||
Some("none") => Some(0),
|
||||
Some("bullet") => presets.iter().position(|(_, key, _)| shown == Some(*key)),
|
||||
_ => None,
|
||||
};
|
||||
label(ui, tl!("Bullet Character:"));
|
||||
let names = presets.map(|(name, _, _)| name);
|
||||
let picked = preset_row(ui, "bullets", &names, current, |painter, rect, i, color| {
|
||||
crate::panels::paragraph::paint_bullet(painter, rect.center(), presets.get(i).and_then(|p| p.2), color);
|
||||
});
|
||||
match picked.and_then(|i| presets.get(i)) {
|
||||
Some((_, _, None)) => pick(d, "none", None),
|
||||
Some((_, key, Some(_))) => pick(d, "bullet", Some(*key)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Numbers tab: None and the number styles in a row, each shown by its first two numbers, the
|
||||
/// one the dialog applies selected.
|
||||
fn number_tab(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let shown = d.fields.get("number").and_then(|v| serde_json::from_value::<NumberStyle>(v.clone()).ok());
|
||||
let current = match d.fields.get("list").and_then(Value::as_str) {
|
||||
Some("none") => Some(0),
|
||||
Some("number") => shown.and_then(|s| NumberStyle::ALL.iter().position(|&n| n == s)).map(|i| i + 1),
|
||||
_ => None,
|
||||
};
|
||||
label(ui, tl!("Number Style:"));
|
||||
// Screen readers name each style by its first three numbers, as the Numbered List presets do.
|
||||
let samples: Vec<String> = NumberStyle::ALL.iter().map(|&s| crate::panels::paragraph::number_sample(s, 3)).collect();
|
||||
let names: Vec<&str> = std::iter::once(tl!("None")).chain(samples.iter().map(String::as_str)).collect();
|
||||
let picked = preset_row(ui, "numbers", &names, current, |painter, rect, i, color| match i.checked_sub(1).and_then(|k| NumberStyle::ALL.get(k)) {
|
||||
None => crate::panels::paragraph::paint_bullet(painter, rect.center(), None, color),
|
||||
Some(&style) => {
|
||||
let first = format!("{} {}", style.format(1), style.format(2));
|
||||
painter.text(rect.center(), egui::Align2::CENTER_CENTER, first, egui::FontId::proportional(11.0), color);
|
||||
}
|
||||
});
|
||||
match picked {
|
||||
Some(0) => pick(d, "none", None),
|
||||
Some(i) => {
|
||||
if let Some(key) = NumberStyle::ALL.get(i - 1).and_then(|s| serde_json::to_value(s).ok()).and_then(|v| v.as_str().map(str::to_string)) {
|
||||
pick(d, "number", Some(&key));
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of preset buttons named `names`, `current` selected, each drawn by `paint` (painter,
|
||||
/// button, index, color). ← and → move the choice along the row from the button with the keyboard
|
||||
/// focus, and the focus with it. Returns the button clicked or moved to.
|
||||
fn preset_row(
|
||||
ui: &mut egui::Ui,
|
||||
salt: &str,
|
||||
names: &[&str],
|
||||
current: Option<usize>,
|
||||
paint: impl Fn(&egui::Painter, egui::Rect, usize, egui::Color32),
|
||||
) -> Option<usize> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ids: Vec<egui::Id> = (0..names.len()).map(|i| egui::Id::unique(("bullets-numbering-preset", salt, i))).collect();
|
||||
let mut picked = None;
|
||||
let mut focused = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (i, (&name, &id)) in names.iter().zip(&ids).enumerate() {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(PRESET, PRESET), egui::Sense::hover());
|
||||
let resp = ui.interact(rect, id, egui::Sense::click());
|
||||
let selected = current == Some(i);
|
||||
let fill = if selected {
|
||||
t.row_selected
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.input
|
||||
};
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::same(3), fill);
|
||||
let (width, border) = if selected || resp.has_focus() { (1.5, t.accent) } else { (1.0, t.input_border) };
|
||||
ui.painter().rect_stroke(rect, egui::CornerRadius::same(3), egui::Stroke::new(width, border), egui::StrokeKind::Inside);
|
||||
paint(ui.painter(), rect, i, t.text_strong);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), selected, name));
|
||||
if resp.has_focus() {
|
||||
focused = Some(i);
|
||||
// ← and → move the choice, not egui's focus.
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(id, ARROWS));
|
||||
}
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
picked = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(i) = focused {
|
||||
let (back, on) = ui.input_mut(|inp| {
|
||||
(inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft), inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight))
|
||||
});
|
||||
let to = if back {
|
||||
i.checked_sub(1)
|
||||
} else if on {
|
||||
Some(i + 1).filter(|&j| j < names.len())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if back || on {
|
||||
// egui moves the focus with an arrow pressed on a button that has just taken the
|
||||
// focus, before its focus-lock filter applies: the row moves it instead.
|
||||
ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
if let Some(j) = to {
|
||||
ui.memory_mut(|m| m.request_focus(ids[j]));
|
||||
picked = Some(j);
|
||||
}
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// Pick a preset of kind `list` (`none`, `bullet` or `number`) with key `key` (for `bullet` or
|
||||
/// `number`). For paragraphs that were in no list and had no indents, the indent fields take the
|
||||
/// hanging indent `text.setFormat` gives a paragraph that joins a list; None puts them back while
|
||||
/// they still hold it.
|
||||
fn pick(d: &mut Dialog, list: &str, key: Option<&str>) {
|
||||
d.fields.insert("list".into(), json!(list));
|
||||
if let Some(key) = key {
|
||||
d.fields.insert(list.into(), json!(key));
|
||||
}
|
||||
let initial = d.fields.get(INITIAL).cloned().unwrap_or_default();
|
||||
let was = |k: &str| initial.get(k).and_then(Value::as_f64);
|
||||
let now = |d: &Dialog| (d.fields.get("leftIndent").and_then(Value::as_f64), d.fields.get("firstLineIndent").and_then(Value::as_f64));
|
||||
let hanging = (Some(LIST_HANGING_INDENT), Some(-LIST_HANGING_INDENT));
|
||||
if list != "none" {
|
||||
let unlisted = initial.get("list").and_then(Value::as_str) == Some("none");
|
||||
if unlisted && (was("leftIndent"), was("firstLineIndent")) == (Some(0.0), Some(0.0)) && now(d) == (Some(0.0), Some(0.0)) {
|
||||
d.fields.insert("leftIndent".into(), json!(LIST_HANGING_INDENT));
|
||||
d.fields.insert("firstLineIndent".into(), json!(-LIST_HANGING_INDENT));
|
||||
d.fields.insert(HANGING.into(), json!(true));
|
||||
}
|
||||
} else if d.fields.remove(HANGING).is_some() && now(d) == hanging {
|
||||
d.fields.insert("leftIndent".into(), json!(0.0));
|
||||
d.fields.insert("firstLineIndent".into(), json!(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Position group: Alignment, Left Indent and First Line Indent.
|
||||
fn position(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) {
|
||||
widgets::section_header(ui, tl!("Position"));
|
||||
egui::Grid::new("bullets-numbering-position").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
label(ui, tl!("Alignment:"));
|
||||
let names = [tl!("Left"), tl!("Right")];
|
||||
let current = d.fields.get("listAlign").and_then(Value::as_str).and_then(|a| ALIGNS.iter().position(|x| *x == a));
|
||||
// Already translated: shown as they are.
|
||||
if let Some(i) = widgets::dropdown_names(ui, ("bullets-numbering", "listAlign"), current.map_or("", |i| names[i]), &names, 100.0)
|
||||
&& let Some(a) = ALIGNS.get(i)
|
||||
{
|
||||
d.fields.insert("listAlign".into(), json!(a));
|
||||
}
|
||||
ui.end_row();
|
||||
label(ui, tl!("Left Indent:"));
|
||||
form::length_within(ui, d, "leftIndent", unit, 90.0, Some(INDENTS));
|
||||
ui.end_row();
|
||||
label(ui, tl!("First Line Indent:"));
|
||||
form::length_within(ui, d, "firstLineIndent", unit, 90.0, Some(INDENTS));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with,
|
||||
/// on the dialog's type and range. With a preset that puts the paragraphs in a list, the indents
|
||||
/// the dialog shows go too, so that the hanging indent `text.setFormat` gives a paragraph joining a
|
||||
/// list does not replace them. Alignment and Level go only when the paragraphs are in a list or
|
||||
/// the preset puts them in one.
|
||||
fn params(d: &Dialog) -> Map<String, Value> {
|
||||
let initial = d.fields.get(INITIAL).and_then(Value::as_object);
|
||||
let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_str);
|
||||
let field = |key: &str| d.fields.get(key).and_then(Value::as_str);
|
||||
let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
match field("list") {
|
||||
Some("none") if was("list") != Some("none") => {
|
||||
p.insert("list".into(), json!("none"));
|
||||
}
|
||||
Some(kind @ ("bullet" | "number")) => match field(kind) {
|
||||
Some(key) if was("list") != Some(kind) || was(kind) != Some(key) => {
|
||||
p.insert(kind.into(), json!(key));
|
||||
}
|
||||
None if was("list") != Some(kind) => {
|
||||
p.insert("list".into(), json!(kind));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
let listing = ["bullet", "number"].iter().any(|k| p.contains_key(*k)) || p.get("list").is_some_and(|l| l.as_str() != Some("none"));
|
||||
// `text.setFormat` sets `listAlign` only on paragraphs in a list.
|
||||
if let Some(a) = field("listAlign")
|
||||
&& was("listAlign") != Some(a)
|
||||
&& (listing || was("list") != Some("none"))
|
||||
{
|
||||
p.insert("listAlign".into(), json!(a));
|
||||
}
|
||||
// …and `listLevel` too.
|
||||
if let Some(l) = d.fields.get("listLevel").and_then(Value::as_f64)
|
||||
&& initial.and_then(|m| m.get("listLevel")).and_then(Value::as_f64) != Some(l)
|
||||
&& (listing || was("list") != Some("none"))
|
||||
{
|
||||
p.insert("listLevel".into(), json!(l));
|
||||
}
|
||||
// For paragraphs in a list and paragraphs in none, an indent left as it was is not sent:
|
||||
// `text.setFormat` then gives each paragraph that joins the list its hanging indent.
|
||||
let mixed = initial.is_some_and(|m| !m.contains_key("list"));
|
||||
for key in ["leftIndent", "firstLineIndent"] {
|
||||
if !d.fields.contains_key(key) {
|
||||
continue;
|
||||
}
|
||||
// A length typed with its unit ("6 mm") is sent in points.
|
||||
let v = d.f64(key, 0.0);
|
||||
let changed = initial.and_then(|m| m.get(key)).and_then(Value::as_f64) != Some(v);
|
||||
if changed || (listing && !mixed) {
|
||||
p.insert(key.into(), json!(v));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Does `p` change anything (more than the targets)?
|
||||
fn changes(p: &Map<String, Value>) -> bool {
|
||||
p.keys().any(|k| !matches!(k.as_str(), "ids" | "start" | "end"))
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let p = params(d);
|
||||
if !changes(&p) {
|
||||
if app.session.in_interaction() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
form::commit_preview(app, CMD, Value::Object(p))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::text::{Bullet, ListAlign};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::doc::{NodeId, NodeKind, ParaStyle};
|
||||
|
||||
/// A selected area type object with two paragraphs.
|
||||
fn app() -> (VectorcraftApp, u64) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
(app, id)
|
||||
}
|
||||
|
||||
fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) {
|
||||
match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first paragraph's list and indents.
|
||||
fn first(app: &VectorcraftApp, id: u64) -> (Option<ParaList>, f64, f64) {
|
||||
let p = paras(app, id).0;
|
||||
(p.list, p.left_indent, p.first_line_indent)
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, key: &str, v: Value) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);
|
||||
}
|
||||
|
||||
fn pick_preset(app: &mut VectorcraftApp, list: &str, key: Option<&str>) {
|
||||
pick(app.ui.dialog.as_mut().unwrap(), list, key);
|
||||
}
|
||||
|
||||
/// `ui.bulletsAndNumbering` opens on the selected type's values; a preset, Alignment and the
|
||||
/// indents show on the type at once, and OK keeps them as one `text.setFormat`, which one Undo
|
||||
/// takes back.
|
||||
#[test]
|
||||
fn ok_applies_the_marker_alignment_and_indents_as_one_undo_step() {
|
||||
let (mut app, id) = app();
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
let d = app.ui.dialog.clone().expect("Bullets and Numbering opened");
|
||||
assert_eq!((d.kind.as_str(), d.str("tab"), d.str("list"), d.str("listAlign")), (KIND, "bullets".into(), "none".into(), "left".into()));
|
||||
assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0));
|
||||
pick_preset(&mut app, "bullet", Some("square"));
|
||||
set(&mut app, "listAlign", json!("right"));
|
||||
set(&mut app, "leftIndent", json!(24));
|
||||
set(&mut app, "firstLineIndent", json!(-12));
|
||||
frame(&mut app);
|
||||
assert!(app.session.in_interaction(), "the preview runs");
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step");
|
||||
let want = Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right));
|
||||
assert_eq!(first(&app, id), (want, 24.0, -12.0), "previewed on the type");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
|
||||
assert_eq!((first(&app, id), paras(&app, id).1.list), ((want, 24.0, -12.0), want));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(first(&app, id), (None, 0.0, 0.0));
|
||||
// The Numbers tab's presets make a numbered list, keeping the alignment the dialog shows.
|
||||
app.run("text.setFormat", json!({"list": "bullet", "listAlign": "right"})).unwrap();
|
||||
app.run("ui.bulletsAndNumbering", json!({"tab": "numbers"})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers");
|
||||
pick_preset(&mut app, "number", Some("upperRoman"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(ListAlign::Right)));
|
||||
// OK with nothing changed adds no undo step, and an unknown tab is an error.
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers", "a numbered list opens on Numbers");
|
||||
frame(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||||
assert!(app.run("ui.bulletsAndNumbering", json!({"tab": "levels"})).is_err());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
assert!(app.run("ui.bulletsAndNumbering", json!({})).is_err(), "no type selected");
|
||||
}
|
||||
|
||||
/// Cancel puts back what the preview changed: the list, its alignment and the indents.
|
||||
#[test]
|
||||
fn cancel_puts_the_type_back_as_it_was() {
|
||||
let (mut app, id) = app();
|
||||
app.run("text.setFormat", json!({"number": "arabic"})).unwrap();
|
||||
let before = first(&app, id);
|
||||
assert_eq!(before, (Some(ParaList::numbered(NumberStyle::Arabic)), 18.0, -18.0));
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
pick_preset(&mut app, "number", Some("lowerLetters"));
|
||||
set(&mut app, "listAlign", json!("right"));
|
||||
set(&mut app, "leftIndent", json!(30));
|
||||
frame(&mut app);
|
||||
assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::LowerLetters).aligned(ListAlign::Right)), 30.0, -18.0));
|
||||
// Another change previews again, still without an undo step.
|
||||
set(&mut app, "leftIndent", json!(36));
|
||||
frame(&mut app);
|
||||
assert_eq!(first(&app, id).1, 36.0);
|
||||
set(&mut app, "leftIndent", json!(30));
|
||||
frame(&mut app);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "the preview adds no undo step");
|
||||
// Changed back in the dialog, the type is as it was.
|
||||
set(&mut app, "listAlign", json!("left"));
|
||||
set(&mut app, "leftIndent", json!(18));
|
||||
pick_preset(&mut app, "number", Some("arabic"));
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "no preview while nothing changes");
|
||||
assert_eq!(first(&app, id), before);
|
||||
pick_preset(&mut app, "none", None);
|
||||
frame(&mut app);
|
||||
assert_eq!(first(&app, id).0, None, "None previews the list off");
|
||||
super::super::cancel(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(first(&app, id), before);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "Cancel leaves no undo step");
|
||||
}
|
||||
|
||||
/// A preset picked for paragraphs in no list and without indents fills in the 18 pt and
|
||||
/// -18 pt hanging indent, and None takes it out again; indents that are set stay, and so do
|
||||
/// indents set back to 0 with the preset.
|
||||
#[test]
|
||||
fn a_preset_fills_in_the_hanging_indent_a_new_list_gets() {
|
||||
let (mut app, id) = app();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
pick_preset(&mut app, "bullet", Some("dot"));
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (18.0, -18.0));
|
||||
pick_preset(&mut app, "number", Some("arabic"));
|
||||
pick_preset(&mut app, "none", None);
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0));
|
||||
assert!(!changes(¶ms(&d)));
|
||||
super::super::cancel(&mut app);
|
||||
app.run("text.setFormat", json!({"leftIndent": 10})).unwrap();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
pick_preset(&mut app, "bullet", Some("dash"));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("leftIndent", 0.0), 10.0);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(first(&app, id), (Some(ParaList::bulleted(Bullet::Dash)), 10.0, 0.0));
|
||||
// Indents set back to 0 with the preset are kept: the hanging indent does not replace them.
|
||||
let (mut app, id) = self::app();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
pick_preset(&mut app, "number", Some("arabic"));
|
||||
set(&mut app, "leftIndent", json!(0));
|
||||
set(&mut app, "firstLineIndent", json!(0));
|
||||
frame(&mut app);
|
||||
assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0), "previewed as shown");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0));
|
||||
}
|
||||
|
||||
/// Paragraphs that differ leave the preset unpicked and the fields blank, and OK sends only
|
||||
/// what was changed: each paragraph keeps its own indents and list.
|
||||
#[test]
|
||||
fn differing_paragraphs_show_blank_fields_and_keep_what_is_left_alone() {
|
||||
let (mut app, id) = app();
|
||||
app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "start": 0, "end": 0})).unwrap();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
for key in ["list", "bullet", "leftIndent"] {
|
||||
assert!(!d.fields.contains_key(key), "{key} is blank");
|
||||
}
|
||||
assert_eq!((d.str("listAlign"), d.f64("firstLineIndent", 1.0)), ("left".into(), 0.0));
|
||||
set(&mut app, "listAlign", json!("right"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let (one, two) = paras(&app, id);
|
||||
assert_eq!((one.list, one.left_indent), (Some(ParaList::bulleted(Bullet::Circle).aligned(ListAlign::Right)), 30.0));
|
||||
assert_eq!((two.list, two.left_indent), (None, 0.0), "the paragraph in no list stays out of one");
|
||||
}
|
||||
|
||||
/// A preset picked for a list paragraph and a paragraph in no list, both without indents, gives
|
||||
/// the paragraph that joins the list the hanging indent, and the list paragraph keeps its indents.
|
||||
#[test]
|
||||
fn a_preset_for_listed_and_unlisted_paragraphs_indents_the_one_joining_the_list() {
|
||||
let (mut app, id) = app();
|
||||
app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 0, "firstLineIndent": 0, "start": 0, "end": 0})).unwrap();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert!(!d.fields.contains_key("list"), "the list kinds differ");
|
||||
assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0));
|
||||
pick_preset(&mut app, "bullet", Some("dot"));
|
||||
frame(&mut app);
|
||||
assert_eq!(paras(&app, id).1.left_indent, 18.0, "previewed with the hanging indent");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let (one, two) = paras(&app, id);
|
||||
let dot = Some(ParaList::bulleted(Bullet::Dot));
|
||||
assert_eq!((one.list, one.left_indent, one.first_line_indent), (dot, 0.0, 0.0));
|
||||
assert_eq!((two.list, two.left_indent, two.first_line_indent), (dot, 18.0, -18.0));
|
||||
}
|
||||
|
||||
/// Alignment changed for paragraphs that stay in no list sends nothing: there is no preview,
|
||||
/// and OK adds no undo step. A preset picked with it takes the alignment.
|
||||
#[test]
|
||||
fn alignment_alone_on_paragraphs_in_no_list_adds_no_undo_step() {
|
||||
let (mut app, id) = app();
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
set(&mut app, "listAlign", json!("right"));
|
||||
assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap())));
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "no preview");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step");
|
||||
assert_eq!(first(&app, id), (None, 0.0, 0.0));
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
set(&mut app, "listAlign", json!("right"));
|
||||
pick_preset(&mut app, "bullet", Some("dot"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right)));
|
||||
}
|
||||
|
||||
/// An indent typed past the 1296 pt `text.setFormat` keeps becomes 1296 pt (or -1296 pt) in the
|
||||
/// field, so the field shows the indent the preview and OK give the type.
|
||||
#[test]
|
||||
fn an_indent_typed_out_of_range_shows_the_indent_the_type_gets() {
|
||||
let (mut app, id) = app();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
// Each frame returns where the label `find` was drawn.
|
||||
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>, find: &str| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().find_map(|s| match &s.shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == find => Some(t.galley.rect.translate(t.pos.to_vec2())),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
frame(&mut app, vec![], "");
|
||||
// The fields' column starts after the longest label, First Line Indent's.
|
||||
let x = frame(&mut app, vec![], "First Line Indent:").expect("First Line Indent is drawn").right() + 30.0;
|
||||
// Click the field beside the label `name` (unless `click` is false, as for a field the Tab
|
||||
// before it reached, which has the keyboard focus with its text selected), type `text` over
|
||||
// it and Tab to commit it.
|
||||
let type_into = |app: &mut VectorcraftApp, name: &str, text: &str, click: bool| {
|
||||
let label = frame(app, vec![], name).unwrap_or_else(|| panic!("{name} is drawn"));
|
||||
let at = egui::pos2(x, label.center().y);
|
||||
let press =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
if click {
|
||||
frame(app, vec![egui::Event::PointerMoved(at)], "");
|
||||
frame(app, vec![press(true), press(false)], "");
|
||||
}
|
||||
frame(app, vec![], "");
|
||||
frame(app, vec![egui::Event::Text(text.into())], "");
|
||||
frame(app, vec![key(egui::Key::Tab)], "");
|
||||
frame(app, vec![], "");
|
||||
};
|
||||
type_into(&mut app, "Left Indent:", "2000 pt", true);
|
||||
type_into(&mut app, "First Line Indent:", "-5000 pt", false);
|
||||
let d = app.ui.dialog.clone().expect("the dialog stays open");
|
||||
assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (1296.0, -1296.0));
|
||||
assert_eq!(first(&app, id), (None, 1296.0, -1296.0), "previewed as shown");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(first(&app, id), (None, 1296.0, -1296.0));
|
||||
}
|
||||
|
||||
/// `ui.dialog.set` sets the fields the dialog does not hold: a bullet or a number for type in
|
||||
/// no list, and the fields left blank where the paragraphs differ.
|
||||
#[test]
|
||||
fn the_control_channel_sets_fields_the_dialog_does_not_hold() {
|
||||
let ctx = egui::Context::default();
|
||||
let (mut app, id) = app();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
for key in ["bullet", "number"] {
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(key), "{key} is not held");
|
||||
}
|
||||
let fields = json!([{"field": "list", "value": "number"}, {"field": "number", "value": "upperRoman"}]);
|
||||
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman)));
|
||||
app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "firstLineIndent": -6, "start": 0, "end": 0})).unwrap();
|
||||
app.run("text.setFormat", json!({"ids": [id], "listAlign": "right", "listLevel": 2, "start": 0, "end": 0})).unwrap();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
let held = app.ui.dialog.as_ref().unwrap().fields.clone();
|
||||
for key in INPUTS {
|
||||
assert!(!held.contains_key(key), "{key} is blank");
|
||||
}
|
||||
let fields = json!([
|
||||
{"field": "list", "value": "bullet"},
|
||||
{"field": "bullet", "value": "square"},
|
||||
{"field": "listAlign", "value": "left"},
|
||||
{"field": "listLevel", "value": 3},
|
||||
{"field": "leftIndent", "value": 24},
|
||||
{"field": "firstLineIndent", "value": -12},
|
||||
]);
|
||||
crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let (one, two) = paras(&app, id);
|
||||
let square = Some(ParaList::bulleted(Bullet::Square).at_level(3));
|
||||
assert_eq!((one.list, one.left_indent, one.first_line_indent), (square, 24.0, -12.0));
|
||||
assert_eq!((two.list, two.left_indent, two.first_line_indent), (square, 24.0, -12.0));
|
||||
}
|
||||
|
||||
/// The Level field opens on the paragraphs' list level (1 for paragraphs in no list, blank
|
||||
/// where they differ), shows a change on the type at once, and OK sends it, with a preset or
|
||||
/// alone.
|
||||
#[test]
|
||||
fn the_level_field_sets_the_list_level() {
|
||||
let (mut app, id) = app();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 1.0, "in no list: level 1");
|
||||
pick_preset(&mut app, "number", Some("arabic"));
|
||||
set(&mut app, "listLevel", json!(3));
|
||||
frame(&mut app);
|
||||
assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3)), "previewed on the type");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(paras(&app, id).1.list, Some(ParaList::numbered(NumberStyle::Arabic).at_level(3)));
|
||||
app.run("text.setFormat", json!({"ids": [id], "listLevel": 5, "start": 0, "end": 0})).unwrap();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("listLevel"), "levels 5 and 3 differ: blank");
|
||||
super::super::cancel(&mut app);
|
||||
app.run("text.setFormat", json!({"ids": [id], "listLevel": 5})).unwrap();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("listLevel", 0.0), 5.0);
|
||||
assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap())), "nothing changed yet");
|
||||
set(&mut app, "listLevel", json!(2));
|
||||
assert_eq!(params(app.ui.dialog.as_ref().unwrap()).get("listLevel"), Some(&json!(2.0)));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!((first(&app, id).0.map(|l| l.level), paras(&app, id).1.list.map(|l| l.level)), (Some(2), Some(2)));
|
||||
}
|
||||
|
||||
/// `text.setFormat` sets `listLevel` only on paragraphs in a list, so a Level change alone on
|
||||
/// paragraphs in no list sends nothing and adds no undo step; with a preset it applies.
|
||||
#[test]
|
||||
fn level_alone_on_paragraphs_in_no_list_adds_no_undo_step() {
|
||||
let (mut app, id) = app();
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
set(&mut app, "listLevel", json!(3));
|
||||
assert!(!changes(¶ms(app.ui.dialog.as_ref().unwrap())));
|
||||
frame(&mut app);
|
||||
assert!(!app.session.in_interaction(), "no preview");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "no undo step");
|
||||
assert_eq!(first(&app, id), (None, 0.0, 0.0));
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
set(&mut app, "listLevel", json!(3));
|
||||
pick_preset(&mut app, "bullet", Some("dot"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(first(&app, id).0, Some(ParaList::bulleted(Bullet::Dot).at_level(3)));
|
||||
}
|
||||
|
||||
/// The presets are radio buttons named for screen readers, the one the dialog applies chosen;
|
||||
/// a click picks one, and ← and → move the choice from the focused one.
|
||||
#[test]
|
||||
fn the_presets_are_named_radio_buttons_that_the_arrow_keys_move_along() {
|
||||
use egui::accesskit::{Role, Toggled};
|
||||
let names = ["None", "Filled Dot", "Dash"];
|
||||
let tree = crate::widgets::accessibility_tests::tree(|ui| {
|
||||
let _ = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {});
|
||||
});
|
||||
assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Filled Dot"), (Role::RadioButton, Some(Toggled::True)));
|
||||
assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Dash"), (Role::RadioButton, Some(Toggled::False)));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
let origin = std::cell::Cell::new(egui::Pos2::ZERO);
|
||||
let run = |events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||||
let mut picked = None;
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
origin.set(ui.cursor().min);
|
||||
picked = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {});
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
picked
|
||||
};
|
||||
let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
assert_eq!(run(vec![]), None);
|
||||
assert_eq!(run(vec![key(egui::Key::ArrowRight)]), None, "no preset has the focus");
|
||||
ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("bullets-numbering-preset", "test", 1usize))));
|
||||
run(vec![]);
|
||||
assert_eq!(run(vec![key(egui::Key::ArrowRight)]), Some(2));
|
||||
assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(1), "the focus moved to Dash");
|
||||
assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(0));
|
||||
assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), None, "None is the first");
|
||||
// The middle of the third button, two buttons and their 4 pt gaps along.
|
||||
let at = origin.get() + egui::vec2(PRESET * 2.5 + 8.0, PRESET * 0.5);
|
||||
let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
run(vec![egui::Event::PointerMoved(at)]);
|
||||
assert_eq!(run(vec![click(true), click(false)]), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -354,6 +354,13 @@ font-technology Variable Променливи
|
||||
Apply Bullets Прилагане на булети
|
||||
Numbered List Номериран списък
|
||||
Apply Numbers Прилагане на номериране
|
||||
Bullets and Numbering… Булети и номериране…
|
||||
Numbers Номера
|
||||
Level: Ниво:
|
||||
Bullet Character: Знак за булет:
|
||||
Number Style: Стил на номерата:
|
||||
Left Indent: Ляв отстъп:
|
||||
First Line Indent: Отстъп на първия ред:
|
||||
Convert to Text Конвертиране в текст
|
||||
Filled Dot Плътна точка
|
||||
Dash Тире
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -332,6 +332,12 @@
|
||||
Apply Bullets Primijeni oznake
|
||||
Numbered List Numerisana lista
|
||||
Apply Numbers Primijeni brojeve
|
||||
Bullets and Numbering… Oznake i numerisanje…
|
||||
Numbers Brojevi
|
||||
Bullet Character: Znak oznake:
|
||||
Number Style: Stil broja:
|
||||
Left Indent: Lijeva uvlaka:
|
||||
First Line Indent: Uvlaka prvog reda:
|
||||
Convert to Text Pretvori u tekst
|
||||
Filled Dot Ispunjena tačka
|
||||
Dash Crtica
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -551,8 +551,10 @@
|
||||
Built-in presets don't change: New… starts an editable copy. Els preajustos integrats no canvien: Nou… inicia una còpia editable.
|
||||
Bulge… Abombament…
|
||||
Bullet Punt de llista
|
||||
Bullet Character: Caràcter del punt de llista:
|
||||
Bullets Punts de llista
|
||||
Bullets and Numbering Punts i numeració
|
||||
Bullets and Numbering… Punts i numeració…
|
||||
Burasagari Burasagari
|
||||
Burlap Arpillera
|
||||
Business Card 3.5 × 2 in Targeta de visita 3,5 × 2 in
|
||||
@@ -1597,6 +1599,7 @@ digits Malayalam Malaiàlam
|
||||
First Artboard Primer tauler
|
||||
First Baseline Primera línia de base
|
||||
First commit Primera confirmació
|
||||
First Line Indent: Sagnia de primera línia:
|
||||
First Page Primera pàgina
|
||||
First-line Left Indent Sagnia esquerra de primera línia
|
||||
Fisheye… Ull de peix…
|
||||
@@ -2170,6 +2173,7 @@ digits Malayalam Malaiàlam
|
||||
Left Grid: Quadrícula esquerra:
|
||||
Left in Front Esquerra al davant
|
||||
Left Indent Sagnia esquerra
|
||||
Left Indent: Sagnia esquerra:
|
||||
Left Plane Pla esquerre
|
||||
Left to Right D’esquerra a dreta
|
||||
Left-Justified Tab Tabulació alineada a l’esquerra
|
||||
@@ -2697,8 +2701,10 @@ digits Malayalam Malaiàlam
|
||||
Number of Decimals: Nombre de decimals:
|
||||
Number of Recent Files to Display Nombre de fitxers recents que es mostraran
|
||||
Number of Recent Fonts Nombre de tipus de lletra recents
|
||||
Number Style: Estil de número:
|
||||
Number: Número:
|
||||
Numbered List Llista numerada
|
||||
Numbers Números
|
||||
Numbers Without Units Are Points Els nombres sense unitats són punts
|
||||
Object Objecte
|
||||
Object Highlighting Ressaltat d’objectes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -340,6 +340,13 @@
|
||||
Apply Bullets Použít odrážky
|
||||
Numbered List Číslovaný seznam
|
||||
Apply Numbers Použít číslování
|
||||
Bullets and Numbering… Odrážky a číslování…
|
||||
Numbers Čísla
|
||||
Level: Úroveň:
|
||||
Bullet Character: Znak odrážky:
|
||||
Number Style: Styl číslování:
|
||||
Left Indent: Odsazení zleva:
|
||||
First Line Indent: Odsazení prvního řádku:
|
||||
Convert to Text Převést na text
|
||||
Filled Dot Plná tečka
|
||||
Dash Pomlčka
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -299,6 +299,13 @@
|
||||
Apply Bullets Anvend kugletegn
|
||||
Numbered List Nummereret liste
|
||||
Apply Numbers Anvend nummerering
|
||||
Bullets and Numbering… Kugletegn og nummerering…
|
||||
Numbers Tal
|
||||
Level: Niveau:
|
||||
Bullet Character: Kugletegn:
|
||||
Number Style: Talformat:
|
||||
Left Indent: Venstre indrykning:
|
||||
First Line Indent: Indrykning af første linje:
|
||||
Convert to Text Konvertér til tekst
|
||||
Filled Dot Udfyldt prik
|
||||
Dash Tankestreg
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -342,6 +342,13 @@ font-technology Variable Variabel
|
||||
Apply Bullets Aufzählungszeichen anwenden
|
||||
Numbered List Nummerierte Liste
|
||||
Apply Numbers Nummerierung anwenden
|
||||
Bullets and Numbering… Aufzählungszeichen und Nummerierung…
|
||||
Numbers Nummern
|
||||
Level: Listenebene:
|
||||
Bullet Character: Aufzählungszeichen:
|
||||
Number Style: Nummernformat:
|
||||
Left Indent: Einzug links:
|
||||
First Line Indent: Einzug erste Zeile:
|
||||
Convert to Text In Text umwandeln
|
||||
Filled Dot Gefüllter Punkt
|
||||
Dash Strich
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -342,6 +342,13 @@ font-technology Variable Μεταβλητές
|
||||
Apply Bullets Εφαρμογή κουκκίδων
|
||||
Numbered List Αριθμημένη λίστα
|
||||
Apply Numbers Εφαρμογή αρίθμησης
|
||||
Bullets and Numbering… Κουκκίδες και αρίθμηση…
|
||||
Numbers Αριθμοί
|
||||
Level: Επίπεδο:
|
||||
Bullet Character: Χαρακτήρας κουκκίδας:
|
||||
Number Style: Στυλ αρίθμησης:
|
||||
Left Indent: Αριστερή εσοχή:
|
||||
First Line Indent: Εσοχή πρώτης γραμμής:
|
||||
Convert to Text Μετατροπή σε κείμενο
|
||||
Filled Dot Γεμάτη κουκκίδα
|
||||
Dash Παύλα
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 168 and column 5.
|
@@ -367,6 +367,13 @@ font-technology Variable Variables
|
||||
Apply Bullets Aplicar viñetas
|
||||
Numbered List Lista numerada
|
||||
Apply Numbers Aplicar numeración
|
||||
Bullets and Numbering… Viñetas y numeración…
|
||||
Numbers Números
|
||||
Level: Nivel:
|
||||
Bullet Character: Carácter de viñeta:
|
||||
Number Style: Estilo de número:
|
||||
Left Indent: Sangría izquierda:
|
||||
First Line Indent: Sangría de primera línea:
|
||||
Convert to Text Convertir en texto
|
||||
Filled Dot Punto relleno
|
||||
Dash Guion
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -337,6 +337,13 @@
|
||||
Apply Bullets Käytä luettelomerkkejä
|
||||
Numbered List Numeroitu luettelo
|
||||
Apply Numbers Käytä numerointia
|
||||
Bullets and Numbering… Luettelomerkit ja numerointi…
|
||||
Numbers Numerot
|
||||
Level: Taso:
|
||||
Bullet Character: Luettelomerkki:
|
||||
Number Style: Numerotyyli:
|
||||
Left Indent: Vasen sisennys:
|
||||
First Line Indent: Ensimmäisen rivin sisennys:
|
||||
Convert to Text Muunna tekstiksi
|
||||
Filled Dot Täytetty piste
|
||||
Dash Ajatusviiva
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -368,6 +368,13 @@ font-technology Variable Variables
|
||||
Apply Bullets Appliquer les puces
|
||||
Numbered List Liste numérotée
|
||||
Apply Numbers Appliquer la numérotation
|
||||
Bullets and Numbering… Puces et numérotation…
|
||||
Numbers Numéros
|
||||
Level: Niveau :
|
||||
Bullet Character: Caractère de puce :
|
||||
Number Style: Style de numéro :
|
||||
Left Indent: Retrait gauche :
|
||||
First Line Indent: Retrait de première ligne :
|
||||
Convert to Text Convertir en texte
|
||||
Filled Dot Point plein
|
||||
Dash Tiret
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -504,6 +504,13 @@ font-technology Variable Változó
|
||||
Apply Bullets Felsorolásjelek alkalmazása
|
||||
Numbered List Számozott lista
|
||||
Apply Numbers Számozás alkalmazása
|
||||
Bullets and Numbering… Felsorolásjelek és számozás…
|
||||
Numbers Számok
|
||||
Level: Szint:
|
||||
Bullet Character: Felsorolásjel:
|
||||
Number Style: Számozás stílusa:
|
||||
Left Indent: Bal behúzás:
|
||||
First Line Indent: Első sor behúzása:
|
||||
Convert to Text Átalakítás szöveggé
|
||||
Filled Dot Kitöltött pont
|
||||
Dash Gondolatjel
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -299,6 +299,13 @@
|
||||
Apply Bullets Terapkan poin
|
||||
Numbered List Daftar bernomor
|
||||
Apply Numbers Terapkan penomoran
|
||||
Bullets and Numbering… Poin dan penomoran…
|
||||
Numbers Nomor
|
||||
Level: Tingkat:
|
||||
Bullet Character: Karakter poin:
|
||||
Number Style: Gaya nomor:
|
||||
Left Indent: Indentasi kiri:
|
||||
First Line Indent: Indentasi baris pertama:
|
||||
Convert to Text Konversi ke teks
|
||||
Filled Dot Titik penuh
|
||||
Dash Tanda pisah
|
||||
|
||||
|
@@ -367,6 +367,13 @@ font-technology Variable Variabili
|
||||
Apply Bullets Applica punti elenco
|
||||
Numbered List Elenco numerato
|
||||
Apply Numbers Applica numerazione
|
||||
Bullets and Numbering… Punti elenco e numerazione…
|
||||
Numbers Numeri
|
||||
Level: Livello:
|
||||
Bullet Character: Carattere punto elenco:
|
||||
Number Style: Stile numero:
|
||||
Left Indent: Rientro sinistro:
|
||||
First Line Indent: Rientro prima riga:
|
||||
Convert to Text Converti in testo
|
||||
Filled Dot Punto pieno
|
||||
Dash Trattino
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -355,6 +355,13 @@ digits Malayalam マラヤーラム数字
|
||||
Apply Bullets 箇条書きを適用
|
||||
Numbered List 番号付きリスト
|
||||
Apply Numbers 番号付けを適用
|
||||
Bullets and Numbering… 箇条書きと番号付け…
|
||||
Numbers 番号
|
||||
Level: レベル:
|
||||
Bullet Character: 箇条書き記号:
|
||||
Number Style: 番号の形式:
|
||||
Left Indent: 左インデント:
|
||||
First Line Indent: 1 行目インデント:
|
||||
Convert to Text テキストに変換
|
||||
Filled Dot 黒丸
|
||||
Dash ダッシュ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -266,6 +266,13 @@
|
||||
Apply Bullets ბურთულების გამოყენება
|
||||
Numbered List დანომრილი სია
|
||||
Apply Numbers დანომრვის გამოყენება
|
||||
Bullets and Numbering… ბურთულები და დანომრვა…
|
||||
Numbers ნომრები
|
||||
Level: დონე:
|
||||
Bullet Character: ბურთულის სიმბოლო:
|
||||
Number Style: ნომრის სტილი:
|
||||
Left Indent: მარცხენა შეწევა:
|
||||
First Line Indent: პირველი სტრიქონის შეწევა:
|
||||
Convert to Text ტექსტად გარდაქმნა
|
||||
Filled Dot სავსე წერტილი
|
||||
Dash ტირე
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -348,6 +348,13 @@ digits Malayalam 말라얄람어
|
||||
Apply Bullets 글머리 기호 적용
|
||||
Numbered List 번호 매기기 목록
|
||||
Apply Numbers 번호 매기기 적용
|
||||
Bullets and Numbering… 글머리 기호 및 번호 매기기…
|
||||
Numbers 번호
|
||||
Level: 수준:
|
||||
Bullet Character: 글머리 기호 문자:
|
||||
Number Style: 번호 스타일:
|
||||
Left Indent: 왼쪽 들여쓰기:
|
||||
First Line Indent: 첫 줄 들여쓰기:
|
||||
Convert to Text 텍스트로 변환
|
||||
Filled Dot 채운 점
|
||||
Dash 대시
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -313,6 +313,13 @@
|
||||
Apply Bullets Opsommingstekens toepassen
|
||||
Numbered List Genummerde lijst
|
||||
Apply Numbers Nummering toepassen
|
||||
Bullets and Numbering… Opsommingstekens en nummering…
|
||||
Numbers Nummers
|
||||
Level: Niveau:
|
||||
Bullet Character: Opsommingsteken:
|
||||
Number Style: Nummerstijl:
|
||||
Left Indent: Links inspringen:
|
||||
First Line Indent: Eerste regel inspringen:
|
||||
Convert to Text Converteren naar tekst
|
||||
Filled Dot Gevulde stip
|
||||
Dash Streepje
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -300,6 +300,13 @@
|
||||
Apply Bullets Zastosuj punktory
|
||||
Numbered List Lista numerowana
|
||||
Apply Numbers Zastosuj numerację
|
||||
Bullets and Numbering… Punktory i numeracja…
|
||||
Numbers Numery
|
||||
Level: Poziom:
|
||||
Bullet Character: Znak punktora:
|
||||
Number Style: Styl numeracji:
|
||||
Left Indent: Wcięcie z lewej:
|
||||
First Line Indent: Wcięcie pierwszego wiersza:
|
||||
Convert to Text Konwertuj na tekst
|
||||
Filled Dot Pełna kropka
|
||||
Dash Myślnik
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -338,6 +338,13 @@
|
||||
Apply Bullets Aplicar Marcadores
|
||||
Numbered List Lista Numerada
|
||||
Apply Numbers Aplicar Numeração
|
||||
Bullets and Numbering… Marcadores e Numeração…
|
||||
Numbers Números
|
||||
Level: Nível:
|
||||
Bullet Character: Caractere do Marcador:
|
||||
Number Style: Estilo de Número:
|
||||
Left Indent: Recuo Esquerdo:
|
||||
First Line Indent: Recuo da Primeira Linha:
|
||||
Convert to Text Converter em Texto
|
||||
Filled Dot Ponto Preenchido
|
||||
Dash Travessão
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -365,6 +365,13 @@ font-technology Variable Вариативные
|
||||
Apply Bullets Применить маркеры
|
||||
Numbered List Нумерованный список
|
||||
Apply Numbers Применить нумерацию
|
||||
Bullets and Numbering… Маркеры и нумерация…
|
||||
Numbers Номера
|
||||
Level: Уровень:
|
||||
Bullet Character: Символ маркера:
|
||||
Number Style: Стиль нумерации:
|
||||
Left Indent: Отступ слева:
|
||||
First Line Indent: Отступ первой строки:
|
||||
Convert to Text Преобразовать в текст
|
||||
Filled Dot Закрашенная точка
|
||||
Dash Тире
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -356,6 +356,13 @@ font-technology Variable Variabla
|
||||
Apply Bullets Använd punkter
|
||||
Numbered List Numrerad lista
|
||||
Apply Numbers Använd numrering
|
||||
Bullets and Numbering… Punkter och numrering…
|
||||
Numbers Nummer
|
||||
Level: Nivå:
|
||||
Bullet Character: Punkttecken:
|
||||
Number Style: Nummerformat:
|
||||
Left Indent: Vänsterindrag:
|
||||
First Line Indent: Indrag för första raden:
|
||||
Convert to Text Konvertera till text
|
||||
Filled Dot Fylld punkt
|
||||
Dash Tankstreck
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -351,6 +351,13 @@ font-technology Variable Değişken
|
||||
Apply Bullets Madde İşaretlerini Uygula
|
||||
Numbered List Numaralı Liste
|
||||
Apply Numbers Numaralandırmayı Uygula
|
||||
Bullets and Numbering… Madde İşaretleri ve Numaralandırma…
|
||||
Numbers Numaralar
|
||||
Level: Düzey:
|
||||
Bullet Character: Madde İşareti Karakteri:
|
||||
Number Style: Numara Stili:
|
||||
Left Indent: Sol Girinti:
|
||||
First Line Indent: İlk Satır Girintisi:
|
||||
Convert to Text Metne Dönüştür
|
||||
Filled Dot Dolu Nokta
|
||||
Dash Tire
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -361,6 +361,13 @@ font-technology Variable Варіативні
|
||||
Apply Bullets Застосувати маркери
|
||||
Numbered List Нумерований список
|
||||
Apply Numbers Застосувати нумерацію
|
||||
Bullets and Numbering… Маркери та нумерація…
|
||||
Numbers Номери
|
||||
Level: Рівень:
|
||||
Bullet Character: Символ маркера:
|
||||
Number Style: Стиль нумерації:
|
||||
Left Indent: Відступ зліва:
|
||||
First Line Indent: Відступ першого рядка:
|
||||
Convert to Text Перетворити на текст
|
||||
Filled Dot Заповнена крапка
|
||||
Dash Тире
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -422,6 +422,13 @@ font-technology Variable Biến thiên
|
||||
Apply Bullets Áp dụng dấu đầu dòng
|
||||
Numbered List Danh sách đánh số
|
||||
Apply Numbers Áp dụng đánh số
|
||||
Bullets and Numbering… Dấu đầu dòng và đánh số…
|
||||
Numbers Số
|
||||
Level: Cấp độ:
|
||||
Bullet Character: Ký tự đầu dòng:
|
||||
Number Style: Kiểu đánh số:
|
||||
Left Indent: Lề trái:
|
||||
First Line Indent: Thụt lề dòng đầu:
|
||||
Convert to Text Chuyển thành văn bản
|
||||
Filled Dot Chấm đặc
|
||||
Dash Gạch ngang
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -316,6 +316,13 @@
|
||||
Apply Bullets 应用项目符号
|
||||
Numbered List 编号列表
|
||||
Apply Numbers 应用编号
|
||||
Bullets and Numbering… 项目符号和编号…
|
||||
Numbers 编号
|
||||
Level: 级别:
|
||||
Bullet Character: 项目符号字符:
|
||||
Number Style: 编号样式:
|
||||
Left Indent: 左缩进:
|
||||
First Line Indent: 首行缩进:
|
||||
Convert to Text 转换为文本
|
||||
Filled Dot 实心圆点
|
||||
Dash 短划线
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -329,6 +329,13 @@ font-technology Variable 可變
|
||||
Apply Bullets 套用項目符號
|
||||
Numbered List 編號清單
|
||||
Apply Numbers 套用編號
|
||||
Bullets and Numbering… 項目符號與編號…
|
||||
Numbers 編號
|
||||
Level: 層級:
|
||||
Bullet Character: 項目符號字元:
|
||||
Number Style: 編號樣式:
|
||||
Left Indent: 左縮排:
|
||||
First Line Indent: 首行縮排:
|
||||
Convert to Text 轉換為文字
|
||||
Filled Dot 實心圓點
|
||||
Dash 短破折號
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -270,6 +270,7 @@ fn run_ui_part3(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
}
|
||||
"type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p),
|
||||
"type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p),
|
||||
"ui.bulletsAndNumbering" => crate::dialogs::bullets_numbering::open(app, p),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
||||
@@ -234,6 +234,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off",
|
||||
),
|
||||
(
|
||||
"ui.bulletsAndNumbering",
|
||||
"Bullets and Numbering…",
|
||||
"",
|
||||
"{tab?: \"bullets\"|\"numbers\"} open Bullets and Numbering (More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), listLevel (1..9; 1 for paragraphs in no list), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab",
|
||||
),
|
||||
(
|
||||
"type.convertListToText",
|
||||
"Convert to Text",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), the
|
||||
//! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate,
|
||||
//! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian
|
||||
//! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and
|
||||
//! Hyphenation… and picks the Single-line or Every-line Composer.
|
||||
//! Bullets and Numbered List buttons and their presets (More Options opens Bullets and
|
||||
//! Numbering…), indents, space before/after, Hyphenate, Insert Kashidas (with the Indic options),
|
||||
//! Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu turns Roman Hanging
|
||||
//! Punctuation on and off, opens Justification… and Hyphenation… and picks the Single-line or
|
||||
//! Every-line Composer.
|
||||
|
||||
use egui::{Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -218,7 +219,7 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) {
|
||||
}
|
||||
|
||||
/// A distance shared by every paragraph in scope, or `None` when they differ (blank spin field).
|
||||
fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option<f64> {
|
||||
pub(crate) fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option<f64> {
|
||||
let mut acc = Shared::None;
|
||||
let mut any = false;
|
||||
visit_paragraphs(app, |p| {
|
||||
@@ -251,7 +252,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option<SingleW
|
||||
}
|
||||
|
||||
/// A paragraph attribute shared by every paragraph in scope, or `None` when they differ.
|
||||
fn shared_para<T: PartialEq + Copy>(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option<T> {
|
||||
pub(crate) fn shared_para<T: PartialEq + Copy>(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option<T> {
|
||||
let mut acc = Shared::None;
|
||||
let mut any = false;
|
||||
visit_paragraphs(app, |p| {
|
||||
@@ -465,6 +466,25 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result<Value
|
||||
run_on_paragraphs(app, "text.setFormat", params)
|
||||
}
|
||||
|
||||
/// The Bullets presets, in the order they are listed: (name, `text.setFormat`'s `bullet`, bullet;
|
||||
/// None for the preset that turns the list off).
|
||||
pub(crate) fn bullet_presets() -> [(&'static str, &'static str, Option<vectorcraft_doc::text::Bullet>); 5] {
|
||||
use vectorcraft_doc::text::Bullet;
|
||||
[
|
||||
(tl!("None"), "none", None),
|
||||
(tl!("Filled Dot"), "dot", Some(Bullet::Dot)),
|
||||
(tl!("Dash"), "dash", Some(Bullet::Dash)),
|
||||
(tl!("Hollow Circle"), "circle", Some(Bullet::Circle)),
|
||||
(tl!("Filled Square"), "square", Some(Bullet::Square)),
|
||||
]
|
||||
}
|
||||
|
||||
/// The first `n` numbers of number style `style`, as the Numbered List presets show them ("1, 2,
|
||||
/// 3").
|
||||
pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -> String {
|
||||
(1..=n).map(|k| style.format(k)).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the
|
||||
/// list paragraphs of the selected type, or of those the Type tool's selection touches, become
|
||||
/// characters of the text and the lists are turned off (`text.convertListToText`, which also moves
|
||||
@@ -477,9 +497,9 @@ pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, St
|
||||
/// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs
|
||||
/// the panel acts on a bulleted list (`text.setFormat {list: "bullet"}`) and shows pressed while
|
||||
/// they all are one. The arrow lists the presets (`{bullet}`): None turns the list off, and the
|
||||
/// one every paragraph has is checked.
|
||||
/// one every paragraph has is checked. More Options after them opens Bullets and Numbering on its
|
||||
/// Bullets tab.
|
||||
pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
use vectorcraft_doc::text::Bullet;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet));
|
||||
if widgets::icon_button(ui, "dc-para-bullets", tl!("Bullets"), matches!(shown, Some(Some(_))), size).clicked() {
|
||||
@@ -492,31 +512,28 @@ pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim);
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Bullets")));
|
||||
let arrow = arrow.on_hover_text(tl!("Bullets"));
|
||||
let presets = [
|
||||
(tl!("None"), "none", None),
|
||||
(tl!("Filled Dot"), "dot", Some(Bullet::Dot)),
|
||||
(tl!("Dash"), "dash", Some(Bullet::Dash)),
|
||||
(tl!("Hollow Circle"), "circle", Some(Bullet::Circle)),
|
||||
(tl!("Filled Square"), "square", Some(Bullet::Square)),
|
||||
];
|
||||
let mut more = false;
|
||||
let picked = egui::Popup::menu(&arrow).show(|ui| {
|
||||
let mut picked = None;
|
||||
for (name, key, bullet) in presets {
|
||||
for (name, key, bullet) in bullet_presets() {
|
||||
if bullet_row(ui, name, bullet, shown == Some(bullet)) {
|
||||
picked = Some(key);
|
||||
}
|
||||
}
|
||||
more = more_options_row(ui);
|
||||
picked
|
||||
});
|
||||
if let Some(key) = picked.and_then(|r| r.inner) {
|
||||
format(app, json!({ "bullet": key }));
|
||||
}
|
||||
if more {
|
||||
more_options(app, "bullets");
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the Bullets presets: a check when `checked`, the bullet drawn (a slash for None), then
|
||||
/// its name. Whether it was clicked.
|
||||
fn bullet_row(ui: &mut Ui, name: &str, bullet: Option<vectorcraft_doc::text::Bullet>, checked: bool) -> bool {
|
||||
use vectorcraft_doc::text::Bullet;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click());
|
||||
if resp.hovered() {
|
||||
@@ -527,24 +544,53 @@ fn bullet_row(ui: &mut Ui, name: &str, bullet: Option<vectorcraft_doc::text::Bul
|
||||
if checked {
|
||||
painter.text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "✓", font.clone(), t.text_strong);
|
||||
}
|
||||
let c = rect.left_center() + vec2(28.0, 0.0);
|
||||
let stroke = egui::Stroke::new(1.5, t.text_strong);
|
||||
match bullet {
|
||||
None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke),
|
||||
Some(Bullet::Dot) => painter.circle_filled(c, 3.0, t.text_strong),
|
||||
Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke),
|
||||
Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, t.text_strong)),
|
||||
Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, t.text_strong),
|
||||
};
|
||||
paint_bullet(painter, rect.left_center() + vec2(28.0, 0.0), bullet, t.text_strong);
|
||||
painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, name));
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// Draw `bullet` centered on `c` in `color` as the presets show it (a slash for None).
|
||||
pub(crate) fn paint_bullet(painter: &egui::Painter, c: egui::Pos2, bullet: Option<vectorcraft_doc::text::Bullet>, color: egui::Color32) {
|
||||
use vectorcraft_doc::text::Bullet;
|
||||
let stroke = egui::Stroke::new(1.5, color);
|
||||
match bullet {
|
||||
None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke),
|
||||
Some(Bullet::Dot) => painter.circle_filled(c, 3.0, color),
|
||||
Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke),
|
||||
Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, color)),
|
||||
Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, color),
|
||||
};
|
||||
}
|
||||
|
||||
/// More Options at the end of the Bullets and Numbered List presets, under a line. Whether it was
|
||||
/// clicked.
|
||||
fn more_options_row(ui: &mut Ui) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.separator();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let name = tl!("More Options");
|
||||
ui.painter().text(rect.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text_strong);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name));
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// More Options: open Bullets and Numbering on `tab` (`ui.bulletsAndNumbering`), or show why it
|
||||
/// can't open.
|
||||
fn more_options(app: &mut VectorcraftApp, tab: &str) {
|
||||
if let Err(e) = app.run("ui.bulletsAndNumbering", json!({ "tab": tab })) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Numbered List button and the arrow beside it (Paragraph panel). The button makes the
|
||||
/// paragraphs the panel acts on a numbered list (`text.setFormat {list: "number"}`) and shows
|
||||
/// pressed while they all are one. The arrow lists the presets (`{number}`), each shown by its
|
||||
/// first three numbers: None turns the list off, and the one every paragraph has is checked.
|
||||
/// first three numbers: None turns the list off, and the one every paragraph has is checked. More
|
||||
/// Options after them opens Bullets and Numbering on its Numbers tab.
|
||||
pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
use vectorcraft_doc::text::NumberStyle;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -559,22 +605,26 @@ pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim);
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Numbered List")));
|
||||
let arrow = arrow.on_hover_text(tl!("Numbered List"));
|
||||
let mut more = false;
|
||||
let picked = egui::Popup::menu(&arrow).show(|ui| {
|
||||
let mut picked = None;
|
||||
if bullet_row(ui, tl!("None"), None, shown == Some(None)) {
|
||||
picked = Some(None);
|
||||
}
|
||||
for style in NumberStyle::ALL {
|
||||
let sample = (1..=3).map(|n| style.format(n)).collect::<Vec<_>>().join(", ");
|
||||
if number_row(ui, &sample, shown == Some(Some(style))) {
|
||||
if number_row(ui, &number_sample(style, 3), shown == Some(Some(style))) {
|
||||
picked = Some(Some(style));
|
||||
}
|
||||
}
|
||||
more = more_options_row(ui);
|
||||
picked
|
||||
});
|
||||
if let Some(style) = picked.and_then(|r| r.inner) {
|
||||
format(app, json!({ "number": style.map_or(json!("none"), |s| json!(s)) }));
|
||||
}
|
||||
if more {
|
||||
more_options(app, "numbers");
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the Numbered List presets: a check when `checked`, then the style's first numbers
|
||||
|
||||
@@ -435,6 +435,36 @@ fn the_numbered_list_button_and_its_presets_set_the_list_and_reset_panel_clears_
|
||||
assert_eq!(lists(&app), (None, None));
|
||||
}
|
||||
|
||||
/// More Options at the end of the Bullets presets opens Bullets and Numbering on its Bullets
|
||||
/// tab, and at the end of the Numbered List presets on its Numbers tab.
|
||||
#[test]
|
||||
fn more_options_opens_bullets_and_numbering_on_its_tab() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let (mut app, _) = editing_text("One\nTwo");
|
||||
select_range(&mut app, 0, 0);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let numbers = std::cell::Cell::new(false);
|
||||
let row = std::cell::Cell::new(egui::Rect::NOTHING);
|
||||
let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| {
|
||||
let r = ui.horizontal(|ui| if numbers.get() { numbers_button(app, ui, 28.0) } else { bullets_button(app, ui, 28.0) });
|
||||
row.set(r.response.rect);
|
||||
};
|
||||
for (on_numbers, tab) in [(false, "bullets"), (true, "numbers")] {
|
||||
numbers.set(on_numbers);
|
||||
super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let arrow = row.get().min + vec2(28.0 + 7.0, 14.0);
|
||||
super::super::tests_appearance::click(&ctx, &mut app, arrow, &mut draw);
|
||||
let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw);
|
||||
let at = super::super::tests_appearance::text_rect(&texts, "More Options").center();
|
||||
super::super::tests_appearance::click(&ctx, &mut app, at, &mut draw);
|
||||
let d = app.ui.dialog.take().unwrap_or_else(|| panic!("More Options opens the dialog from the {tab} presets"));
|
||||
assert_eq!((d.kind.as_str(), d.str("tab")), ("bulletsAndNumbering", tab.to_string()));
|
||||
}
|
||||
app.run("ui.bulletsAndNumbering", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("bulletsAndNumbering"));
|
||||
}
|
||||
|
||||
/// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the
|
||||
/// paragraphs the Type tool's selection touches: their numbers become characters of the text
|
||||
/// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret
|
||||
|
||||
@@ -57,8 +57,11 @@ enum IndentMarker {
|
||||
struct IndentTarget {
|
||||
id: u64,
|
||||
start: usize,
|
||||
/// The left indent as laid out: the paragraph's own plus its list level's ([`Self::level`]).
|
||||
left: f64,
|
||||
first_line: f64,
|
||||
/// How much further in the paragraph's list level sets it than its own left indent.
|
||||
level: f64,
|
||||
}
|
||||
|
||||
fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &mut Vec<IndentTarget>) {
|
||||
@@ -68,7 +71,8 @@ fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &m
|
||||
starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1)));
|
||||
targets.extend(range.filter_map(|i| {
|
||||
let para = text.para_at(i);
|
||||
Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left: para.left_indent, first_line: para.first_line_indent })
|
||||
let left = para.laid_out_left_indent();
|
||||
Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left, first_line: para.first_line_indent, level: left - para.left_indent })
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -113,13 +117,14 @@ fn bounded_marker_position(marker: IndentMarker, target: f64, span: f64, targets
|
||||
IndentMarker::Upper => {
|
||||
(targets.iter().map(|p| p.left - 1296.0).fold(0.0_f64, f64::max), targets.iter().map(|p| p.left + 1296.0).fold(span, f64::min))
|
||||
}
|
||||
// A paragraph's own left indent stays within 0 to 1296 pt, its list level's indent added.
|
||||
IndentMarker::Lower if both => (
|
||||
targets.iter().map(|p| -p.first_line).fold(0.0_f64, f64::max),
|
||||
targets.iter().map(|p| span - p.first_line).fold(span.min(1296.0), f64::min),
|
||||
targets.iter().map(|p| p.level.max(-p.first_line)).fold(0.0_f64, f64::max),
|
||||
targets.iter().map(|p| (span - p.first_line).min(p.level + 1296.0)).fold(span, f64::min),
|
||||
),
|
||||
IndentMarker::Lower => (
|
||||
targets.iter().map(|p| p.left + p.first_line - 1296.0).fold(0.0_f64, f64::max),
|
||||
targets.iter().map(|p| p.left + p.first_line + 1296.0).fold(span.min(1296.0), f64::min),
|
||||
targets.iter().map(|p| p.level.max(p.left + p.first_line - 1296.0)).fold(0.0_f64, f64::max),
|
||||
targets.iter().map(|p| (p.left + p.first_line + 1296.0).min(p.level + 1296.0)).fold(span, f64::min),
|
||||
),
|
||||
};
|
||||
(low <= high).then(|| target.clamp(low, high))
|
||||
@@ -209,28 +214,34 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para:
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
} else if both {
|
||||
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"leftIndent": target})) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if targets.iter().all(|p| p.left + p.first_line == marker_position(IndentMarker::Upper, para)) {
|
||||
if let Err(e) = super::paragraph::run_on_paragraphs(
|
||||
app,
|
||||
"text.setFormat",
|
||||
json!({"leftIndent": target, "firstLineIndent": marker_position(IndentMarker::Upper, para) - target}),
|
||||
) {
|
||||
app.status(e);
|
||||
}
|
||||
} else {
|
||||
// The ruler shows each paragraph's left indent with its list level's added; the paragraph
|
||||
// keeps its own.
|
||||
let upper = marker_position(IndentMarker::Upper, para);
|
||||
let level = targets.first().map(|p| p.level).filter(|l| targets.iter().all(|p| p.level == *l));
|
||||
let params = match level {
|
||||
Some(level) if both => Some(json!({"leftIndent": target - level})),
|
||||
Some(level) if targets.iter().all(|p| p.left + p.first_line == upper) => {
|
||||
Some(json!({"leftIndent": target - level, "firstLineIndent": upper - target}))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(params) = params {
|
||||
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
super::character::end_typing(app);
|
||||
let commands: Vec<Value> = targets
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|p| {
|
||||
json!({"command": "text.setFormat", "params": {
|
||||
"ids": [p.id], "start": p.start, "end": p.start,
|
||||
"leftIndent": target, "firstLineIndent": p.left + p.first_line - target
|
||||
}})
|
||||
let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level});
|
||||
if !both {
|
||||
params["firstLineIndent"] = json!(p.left + p.first_line - target);
|
||||
}
|
||||
json!({"command": "text.setFormat", "params": params})
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) {
|
||||
@@ -245,7 +256,9 @@ 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.
|
||||
/// As [`current`], with all the attributes of the paragraph whose stops the panel shows, its left
|
||||
/// indent as laid out ([`ParaStyle::laid_out_left_indent`]: its list level's indent added), where
|
||||
/// the ruler shows it.
|
||||
fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> {
|
||||
let editing = super::character::text_editing(app);
|
||||
let n = match editing {
|
||||
@@ -258,7 +271,8 @@ fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> {
|
||||
TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
|
||||
_ => 360.0,
|
||||
};
|
||||
let para = t.para_at(para).clone();
|
||||
let para = t.para_at(para);
|
||||
let para = ParaStyle { left_indent: para.laid_out_left_indent(), ..para.clone() };
|
||||
Some((n, para, span.max(72.0)))
|
||||
}
|
||||
|
||||
@@ -638,6 +652,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
snap_to_unit_item(ui);
|
||||
return;
|
||||
};
|
||||
// `current_para` gives the left indent with the list level's indent added.
|
||||
let left_indent = para.left_indent;
|
||||
let mut stops = para.tabs;
|
||||
let selected_marker: Option<IndentMarker> = pstate(ui.ctx(), "tabs-marker-sel");
|
||||
// Clamp the selection to the last stop, as `show` does for the ruler.
|
||||
@@ -655,10 +671,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
apply(app, &stops);
|
||||
}
|
||||
// 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.
|
||||
// previous stop, or from the paragraph's left indent plus its list level's indent when it is
|
||||
// the first stop.
|
||||
if menu_item(ui, tl!("Repeat Tab"), sel < stops.len() && selected_marker.is_none(), false) {
|
||||
let step = match sel {
|
||||
0 => (stops[0].position - para.left_indent).abs(),
|
||||
0 => (stops[0].position - left_indent).abs(),
|
||||
_ => stops[sel].position - stops[sel - 1].position,
|
||||
}
|
||||
.max(1.0);
|
||||
|
||||
@@ -540,9 +540,32 @@ fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame(
|
||||
|
||||
#[test]
|
||||
fn lower_marker_bounds_cover_each_touched_first_line() {
|
||||
let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }];
|
||||
let targets = [
|
||||
IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0, level: 0.0 },
|
||||
IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0, level: 0.0 },
|
||||
];
|
||||
assert_eq!(bounded_marker_position(IndentMarker::Lower, 40.0, 300.0, &targets, true), Some(20.0));
|
||||
assert_eq!(bounded_marker_position(IndentMarker::Lower, 1200.0, 300.0, &targets, false), Some(300.0));
|
||||
// A paragraph at list level 2 is laid out 18 pt further in: its own left indent stays at 0 or more.
|
||||
let listed = [IndentTarget { id: 1, start: 0, left: 18.0, first_line: 0.0, level: 18.0 }];
|
||||
assert_eq!(bounded_marker_position(IndentMarker::Lower, 5.0, 300.0, &listed, true), Some(18.0));
|
||||
}
|
||||
|
||||
/// A list paragraph at level 2 is laid out 18 pt further in than its left indent: the ruler shows
|
||||
/// its markers there, and a marker moved on the ruler sets the paragraph's own left indent.
|
||||
#[test]
|
||||
fn indent_markers_show_and_set_a_list_paragraphs_laid_out_indent() {
|
||||
let mut app = indent_app(10.0, 6.0);
|
||||
app.run("text.setFormat", json!({"list": "bullet", "listLevel": 2})).unwrap();
|
||||
let para = current_para(&app).unwrap().1;
|
||||
assert_eq!((marker_position(IndentMarker::Lower, ¶), marker_position(IndentMarker::Upper, ¶)), (28.0, 34.0));
|
||||
let ctx = egui::Context::default();
|
||||
marker_event(&ctx, &mut app, 28.0, 40.0, false, true, false);
|
||||
assert_eq!(indent_values(&app, 0), (22.0, 6.0), "the ruler's 40 pt is the paragraph's 22 pt and the level's 18");
|
||||
marker_event(&ctx, &mut app, 46.0, 70.0, true, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (22.0, 30.0));
|
||||
marker_event(&ctx, &mut app, 40.0, 20.0, false, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (2.0, 50.0), "the first line stays at 70 pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1100,7 +1123,8 @@ fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// left indent (18 pt further in for each list level past the first), 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());
|
||||
@@ -1119,6 +1143,11 @@ fn repeat_tab_measures_the_first_stop_from_the_left_indent() {
|
||||
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]);
|
||||
// A list paragraph at level 2 is set 18 pt further in, at 38: copies every 12 pt.
|
||||
app.session.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 2})).unwrap();
|
||||
app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}]})).unwrap();
|
||||
choose(&ctx, &mut app, "Repeat Tab");
|
||||
assert_eq!(positions(&app)[..3], [50.0, 62.0, 74.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());
|
||||
|
||||
+48
-4
@@ -5972,13 +5972,37 @@ the text. Return at the end of a list item starts another item in the same list.
|
||||
set, and paragraph styles carry it: a style captured from a paragraph without a list turns the list off. Reset Panel
|
||||
turns it off. The change is one undo step.
|
||||
|
||||
`text.setFormat {listAlign: "left"|"right"}` (Bullets and Numbering › Alignment) sets where the marker stands before
|
||||
the first line's text. With `"left"`, the default, the marker starts where the first line starts, at the left indent
|
||||
plus the first-line indent. With `"right"` the marker ends where the text starts, against the text. The text starts at
|
||||
the same place with either alignment, so numbers of different widths aligned right end at the same place, and a number
|
||||
that reaches past the left indent ends at the next tab stop, where the text starts. In a right-to-left list paragraph a
|
||||
marker aligned right starts where the text ends. `listAlign` sets the paragraphs that are in a list, and those that the
|
||||
same call puts in one; a paragraph in no list stays out of one. A list keeps its alignment when it changes between
|
||||
bullets and numbers, and a list that is turned off and on again starts aligned left. An unknown value is an error and
|
||||
changes nothing. The list saves the alignment (`"align": "right"`) only when it is right.
|
||||
|
||||
`text.setFormat {listLevel: 1..9}` (Bullets and Numbering › Level) sets the level of the list paragraphs it touches, and
|
||||
of those that the same call puts in a list; a paragraph in no list stays out of one. The value is a whole number from 1
|
||||
to 9 (`2` or `2.0`); any other value is an error and changes nothing. Each level past the first
|
||||
sets the paragraph 18 pt further in than its left indent: its marker, its text and the lines it wraps to move together,
|
||||
and in a right-to-left list paragraph they move in from the right. A list keeps its level when it changes between
|
||||
bullets and numbers, and a list that is turned off and on again starts at level 1. The list saves the level
|
||||
(`"level": 3`) only when it is not 1. In the Type tool, Tab with the caret at the start of a list paragraph (before its
|
||||
first character, with nothing selected) runs `text.setFormat {listLevel}` with the next level, and Shift+Tab with the
|
||||
level before; each is one undo step, and the text is unchanged. Tab at level 9 and Shift+Tab at level 1 do nothing.
|
||||
Anywhere else, with characters selected, or in a paragraph in no list, Tab and Shift+Tab insert a tab.
|
||||
|
||||
A numbered paragraph's number counts the numbered paragraphs of its story up to and including it: the count runs through
|
||||
consecutive list paragraphs, through the frames of threaded type in thread order, and starts again at 1 after a
|
||||
paragraph in no list. An empty paragraph has no number and is not counted, and a bulleted paragraph keeps its bullet and
|
||||
is not counted. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and
|
||||
is not counted. Each level counts its own numbers: a list paragraph that has text sets the counts of the levels
|
||||
deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a
|
||||
shallower level goes on with that level's count. Letters run from A to Z, then AA to ZZ, then AAA; roman numerals use IV, IX, XL, XC, CD and CM; 01 and
|
||||
001 keep every digit past 99 and 999; letters and roman numerals past 3999 are written in Arabic digits. In a
|
||||
right-to-left paragraph the number reads left to right with its period at its left. Each threaded frame saves the count
|
||||
that its earlier frames carry into it (`listCount`, only when it is not 0). Type that is in no thread counts from 1:
|
||||
that its earlier frames carry into it at level 1 (`listCount`, only when it is not 0) and at the levels past it
|
||||
(`listLevelCounts`, level 2 first, only when one of them is not 0). Type that is in no thread counts from 1:
|
||||
after Remove Threading or Release Selection, in a frame converted to point type, and in every copy of a frame (pasted,
|
||||
duplicated, copied in the Layers panel, or taken out of a symbol or a placed file). A symbol made of a thread keeps the
|
||||
numbers in the symbol's art.
|
||||
@@ -5999,12 +6023,29 @@ Type › Bullets and Numbering › Apply Bullets and the canvas context menu's B
|
||||
same menus runs `type.applyNumbers {number?}`. Without `number` it runs `text.setFormat {list: "number"}`, and with it
|
||||
it runs `text.setFormat {number}`.
|
||||
|
||||
More Options at the end of the Paragraph panel's Bullets and Numbered List presets runs `ui.bulletsAndNumbering {tab?: "bullets"|"numbers"}`. It opens the Bullets and Numbering
|
||||
dialog (`bulletsAndNumbering`) on the paragraphs the Paragraph panel acts on, on that tab; without `tab` it opens on
|
||||
Numbers when the paragraphs are a numbered list and on Bullets otherwise. On both tabs the Level field, above the
|
||||
presets, holds the paragraphs' list level (1 to 9; 1 for paragraphs in no list). The Bullets tab has the five bullet
|
||||
presets and the Numbers tab has None and the seven number styles. The Position group has Alignment (Left or Right),
|
||||
Left Indent and First Line Indent. The dialog opens with the values the Paragraph panel shows; where the paragraphs
|
||||
differ, no preset is picked and the field is blank. Its fields are `tab`, `list` (`none`, `bullet` or `number`: the
|
||||
kind of the preset picked), `bullet`, `number`, `listAlign`, `listLevel`, `leftIndent` and `firstLineIndent` (points),
|
||||
and `ui.dialog.set` sets them, including those the dialog opened blank or without. The type shows the dialog's values while it is open. A preset picked for paragraphs that are in no list and have
|
||||
no indents fills in 18 pt and -18 pt, and None takes them out again while they are unchanged. OK runs `text.setFormat`
|
||||
with the values that changed, as one undo step, and Cancel puts the type back as it was. When a preset puts the
|
||||
paragraphs in a list, OK also sends the indents the dialog shows, and the paragraphs take them, 0 included. OK does not
|
||||
send an Alignment or Level change for paragraphs that stay in no list. With no type selected, or
|
||||
with an unknown `tab`, `ui.bulletsAndNumbering` is an error.
|
||||
|
||||
Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on
|
||||
those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of
|
||||
each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the
|
||||
paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first
|
||||
character without the attributes the marker leaves out. The paragraph leaves its list. When the marker's tab went to
|
||||
the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that
|
||||
character without the attributes the marker leaves out. The paragraph leaves its list. A paragraph at a list level
|
||||
past the first keeps the level's indent in its left indent, so its marker and text stay where they were. A marker aligned right
|
||||
(`listAlign` `right`) is written the same way: its characters go to the first line's start, where a marker aligned left
|
||||
is drawn, and the text after its tab stays where it was. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that
|
||||
the text after the tab starts where it started after the marker. When the marker's tab went to a tab stop, the
|
||||
converted tab goes to the same place when that stop is left-aligned and no stop there or past it has a leader. When a
|
||||
stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker
|
||||
@@ -6048,6 +6089,9 @@ the paragraph's text now starts; when the text before `at` starts no list, nothi
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}}
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"number":"upperRoman"}}}
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listAlign":"right"}}}
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listLevel":2,"start":4,"end":4}}}
|
||||
{"name":"invoke_menu","arguments":{"command":"ui.bulletsAndNumbering","params":{"tab":"numbers"}}}
|
||||
{"name":"run_command","arguments":{"command":"text.convertListToText","params":{}}}
|
||||
```
|
||||
|
||||
|
||||
@@ -1070,9 +1070,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths.
|
||||
The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting.
|
||||
The Character panel menu's Kashidas switch (with the Indic options; `text.setFormat` / `text.setRangeStyle {kashidas}`) allows Kashidas on selected characters, on by default for new and old text. A pair of joined letters receives one only when both characters allow it; turning one off leaves other eligible pairs in the word available. Character styles and the native file keep the switch. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html) documents the menu control and the separate paragraph length choices; the default and pair rule are VectorCraft choices.
|
||||
The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and list detection while typing are not built yet.
|
||||
The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it.
|
||||
The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet.
|
||||
The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1.
|
||||
More Options in the Bullets and Numbered List presets (`ui.bulletsAndNumbering`) opens the Bullets and Numbering dialog ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): a Bullets tab and a Numbers tab with the presets, and the Position group's Alignment, Left Indent and First Line Indent, shown on the type while the dialog is open; OK applies them with `text.setFormat` (Alignment is its `listAlign`) as one undo step, and Cancel puts the type back. Alignment Left starts the marker at the first line's start, and Right ends it where the text starts. The cited page does not say where the marker sits for each choice, so these meanings are VectorCraft's own rule. Paragraph styles and the native file keep the alignment.
|
||||
Tab and Shift+Tab with the caret at the start of a list paragraph (`text.setFormat {listLevel}`), and the Bullets and Numbering dialog's Level field (OK sends it as `listLevel`), move the paragraph down and up the list's nine levels ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html)): each level past the first sets the paragraph 18 pt further in, each level counts its own numbers from 1, and a paragraph back at a shallower level goes on with that level's count, through threaded frames too. Elsewhere Tab inserts a tab as before; one Undo takes a level change back, and paragraph styles and the native file keep the level. Convert to Text writes each level's numbers and keeps a deeper paragraph's indent.
|
||||
Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted.
|
||||
While no type is being edited, a press with Shift held makes new type with the horizontal or vertical counterpart of the Type, Area Type or Type on a Path tool chosen, and a press with Alt (Option) held makes it with Type on a Path in place of Area Type or the reverse, in the chosen tool's writing direction; with both keys held, both switch ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). The pointer shows the tool the press acts as, and the tool chosen stays selected.
|
||||
The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `+`, `#` or `>` makes it a bulleted list with the filled dot, the hollow circle, the filled square or the dash (a VectorCraft choice: the page does not say which bullet each character gives). The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing.
|
||||
@@ -1084,7 +1086,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point.
|
||||
The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph.
|
||||
When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected.
|
||||
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.
|
||||
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 (18 pt further in for each list level past the first) 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), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional 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).
|
||||
Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`).
|
||||
Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment.
|
||||
|
||||
Reference in New Issue
Block a user