mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Merge pull request #4776 from dotCooCoo/feat/tabs-ruler-indent-markers
Tabs: the ruler's indent markers set the first-line and left indents
This commit is contained in:
@@ -40,9 +40,189 @@ fn align_id(a: TabAlign) -> &'static str {
|
||||
/// The panel body's margin and the ruler's own inset: the ruler's 0 is this far right of the panel.
|
||||
const RULER_LEFT: f32 = 10.0 + 4.0;
|
||||
|
||||
/// The text object, the stops of the caret's paragraph while the Type tool edits text (else the
|
||||
/// first selected text object's first paragraph) and the ruler span in points (the frame width for
|
||||
/// area type).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum IndentMarker {
|
||||
Upper,
|
||||
Lower,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct IndentTarget {
|
||||
id: u64,
|
||||
start: usize,
|
||||
left: f64,
|
||||
first_line: f64,
|
||||
}
|
||||
|
||||
fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &mut Vec<IndentTarget>) {
|
||||
let NodeKind::Text(text) = &node.kind else { return };
|
||||
let plain = text.plain_text();
|
||||
let mut starts = vec![0];
|
||||
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 })
|
||||
}));
|
||||
}
|
||||
|
||||
/// Paragraphs touched by the Type selection, or all paragraphs of selected text objects.
|
||||
fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> {
|
||||
let mut targets = Vec::new();
|
||||
if let Some((_, a, b)) = super::character::text_editing(app) {
|
||||
if let NodeKind::Text(text) = &node.kind {
|
||||
append_indent_targets(node, text.paragraphs_in(a, b), &mut targets);
|
||||
}
|
||||
} else if let Some(st) = app.session.active() {
|
||||
for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
selected.walk(&mut |child| {
|
||||
if let NodeKind::Text(text) = &child.kind {
|
||||
append_indent_targets(child, 0..text.paragraph_count(), &mut targets);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
fn marker_position(marker: IndentMarker, para: &ParaStyle) -> f64 {
|
||||
match marker {
|
||||
IndentMarker::Upper => para.left_indent + para.first_line_indent,
|
||||
IndentMarker::Lower => para.left_indent,
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_marker_position(marker: IndentMarker, target: f64, span: f64, targets: &[IndentTarget], both: bool) -> Option<f64> {
|
||||
if !target.is_finite() || !span.is_finite() || span < 0.0 || targets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (low, high) = match marker {
|
||||
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))
|
||||
}
|
||||
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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
};
|
||||
(low <= high).then(|| target.clamp(low, high))
|
||||
}
|
||||
|
||||
fn hit_marker(pos: egui::Pos2, rect: egui::Rect, para: &ParaStyle, span: f64, mixed_left: bool, to_x: impl Fn(f64) -> f32) -> Option<IndentMarker> {
|
||||
if pos.y < rect.top() || pos.y > rect.top() + 16.0 {
|
||||
return None;
|
||||
}
|
||||
let upper = (to_x(marker_position(IndentMarker::Upper, para).clamp(0.0, span)) - pos.x).abs() <= 6.0;
|
||||
let lower = !mixed_left && (to_x(marker_position(IndentMarker::Lower, para).clamp(0.0, span)) - pos.x).abs() <= 6.0;
|
||||
match (upper, lower) {
|
||||
(true, true) if pos.y >= rect.top() + 8.0 => Some(IndentMarker::Lower),
|
||||
(true, _) => Some(IndentMarker::Upper),
|
||||
(_, true) => Some(IndentMarker::Lower),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
struct MarkerPaint<'a> {
|
||||
rect: egui::Rect,
|
||||
span: f64,
|
||||
para: &'a ParaStyle,
|
||||
mixed_left: bool,
|
||||
selected: Option<IndentMarker>,
|
||||
drag: Option<(IndentMarker, bool)>,
|
||||
preview: Option<(IndentMarker, f64, bool)>,
|
||||
}
|
||||
|
||||
fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: MarkerPaint<'_>, to_x: impl Fn(f64) -> f32) {
|
||||
for marker in [IndentMarker::Upper, IndentMarker::Lower] {
|
||||
if marker == IndentMarker::Lower && view.mixed_left {
|
||||
continue;
|
||||
}
|
||||
let value = match view.preview {
|
||||
Some((IndentMarker::Upper, x, _)) if marker == IndentMarker::Upper => x,
|
||||
Some((IndentMarker::Lower, x, _)) if marker == IndentMarker::Lower => x,
|
||||
Some((IndentMarker::Lower, x, true)) if marker == IndentMarker::Upper => marker_position(marker, view.para) + x - view.para.left_indent,
|
||||
_ => marker_position(marker, view.para),
|
||||
};
|
||||
let x = to_x(value.clamp(0.0, view.span));
|
||||
let top = view.rect.top() + 1.0;
|
||||
let points = if marker == IndentMarker::Upper {
|
||||
vec![egui::pos2(x - 5.0, top), egui::pos2(x + 5.0, top), egui::pos2(x, top + 7.0)]
|
||||
} else {
|
||||
vec![egui::pos2(x - 5.0, top + 15.0), egui::pos2(x + 5.0, top + 15.0), egui::pos2(x, top + 8.0)]
|
||||
};
|
||||
let selected = view.selected == Some(marker) || view.drag.is_some_and(|(m, _)| m == marker);
|
||||
painter.add(egui::Shape::convex_polygon(points, tokens.text, Stroke::new(1.5, if selected { tokens.accent } else { tokens.text_dim })));
|
||||
let name = match marker {
|
||||
IndentMarker::Upper => tl!("First-line Left Indent"),
|
||||
IndentMarker::Lower => tl!("Left Indent"),
|
||||
};
|
||||
let hit =
|
||||
egui::Rect::from_center_size(egui::pos2(x, view.rect.top() + if marker == IndentMarker::Upper { 4.0 } else { 12.0 }), vec2(12.0, 8.0));
|
||||
let hover = ui.interact(hit, ui.scope_id().with(("tabs-indent", marker as u8)), Sense::hover());
|
||||
hover.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, name));
|
||||
hover.on_hover_text(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an absolute ruler position through paragraph-format commands.
|
||||
fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) {
|
||||
if targets.is_empty() || !target.is_finite() {
|
||||
return;
|
||||
}
|
||||
let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return };
|
||||
if marker == IndentMarker::Upper {
|
||||
if targets.iter().all(|p| p.left == para.left_indent) {
|
||||
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) {
|
||||
app.status(e);
|
||||
}
|
||||
} else {
|
||||
super::character::end_typing(app);
|
||||
let commands: Vec<Value> = targets
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({"command": "text.setFormat", "params": {
|
||||
"ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left
|
||||
}})
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) {
|
||||
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 {
|
||||
super::character::end_typing(app);
|
||||
let commands: Vec<Value> = targets
|
||||
.iter()
|
||||
.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
|
||||
}})
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text object, the stops of the active paragraph and the ruler span for panel tests.
|
||||
#[cfg(test)]
|
||||
fn current(app: &VectorcraftApp) -> Option<(Node, Vec<TabStop>, f64)> {
|
||||
current_para(app).map(|(n, para, span)| (n, para.tabs, span))
|
||||
}
|
||||
@@ -124,10 +304,18 @@ fn apply_moved(app: &mut VectorcraftApp, ctx: &egui::Context, stops: &mut Vec<Ta
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some((node, mut stops, span)) = current(app) else {
|
||||
let Some((node, para, span)) = current_para(app) else {
|
||||
super::empty_state(ui, "pilcrow", tl!("No text selected"), tl!("Select a text object to set its tab stops."));
|
||||
return;
|
||||
};
|
||||
let mut stops = para.tabs.clone();
|
||||
let targets = indent_targets(app, &node);
|
||||
let mixed_left = targets.iter().any(|p| p.left != para.left_indent);
|
||||
let stored_marker: Option<IndentMarker> = pstate(ui.ctx(), "tabs-marker-sel");
|
||||
let selected_marker = stored_marker.filter(|marker| !mixed_left || *marker != IndentMarker::Lower);
|
||||
if selected_marker != stored_marker {
|
||||
set_pstate::<Option<IndentMarker>>(ui.ctx(), "tabs-marker-sel", None);
|
||||
}
|
||||
let mut sel: usize = pstate(ui.ctx(), "tabs-sel");
|
||||
if sel >= stops.len() {
|
||||
sel = stops.len().saturating_sub(1);
|
||||
@@ -137,14 +325,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
for (i, (a, label)) in ALIGNS.iter().enumerate() {
|
||||
let on = stops.get(sel).map_or(align_idx == i, |s| s.align == *a);
|
||||
let on = if selected_marker.is_some() { align_idx == i } else { stops.get(sel).map_or(align_idx == i, |s| s.align == *a) };
|
||||
let tip = align_name(*a, vertical);
|
||||
let resp = ui.add(egui::Button::new(egui::RichText::new(*label).size(13.0)).selected(on).min_size(vec2(28.0, 24.0)));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tip));
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
align_idx = i;
|
||||
set_pstate(ui.ctx(), "tabs-align", i);
|
||||
if let Some(s) = stops.get_mut(sel) {
|
||||
if let Some(s) = stops.get_mut(sel).filter(|_| selected_marker.is_none()) {
|
||||
s.align = *a;
|
||||
apply(app, &stops);
|
||||
}
|
||||
@@ -159,18 +347,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("tabs-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(if vertical { tl!("Y:") } else { tl!("X:") }).color(t.text));
|
||||
let x = stops.get(sel).map(|s| s.position);
|
||||
if let Some(v) = widgets::spin_field(ui, "tabs-x", x, app.session.general_unit(), 90.0, 1.0, 0.0, &[])
|
||||
&& let Some(i) = apply_moved(app, ui.ctx(), &mut stops, sel, v.max(0.0))
|
||||
{
|
||||
sel = i;
|
||||
let x = selected_marker.map(|m| marker_position(m, ¶)).or_else(|| stops.get(sel).map(|s| s.position));
|
||||
if let Some(v) = widgets::spin_field(ui, "tabs-x", x, app.session.general_unit(), 90.0, 1.0, 0.0, &[]) {
|
||||
if let Some(marker) = selected_marker {
|
||||
set_marker(app, marker, v, ¶, span, &targets, false);
|
||||
} else if let Some(i) = apply_moved(app, ui.ctx(), &mut stops, sel, v.max(0.0)) {
|
||||
sel = i;
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label(egui::RichText::new(tl!("Leader:")).color(t.text));
|
||||
// The fields keep what is typed until Enter or a click away commits it (#924).
|
||||
let leader = stops.get(sel).map(|s| s.leader.clone()).unwrap_or_default();
|
||||
let leader = stops.get(sel).filter(|_| selected_marker.is_none()).map(|s| s.leader.clone()).unwrap_or_default();
|
||||
let typed = ui
|
||||
.add_enabled_ui(stops.get(sel).is_some(), |ui| widgets::exact_text_field_named(ui, "tabs-leader", Some(tl!("Leader:")), &leader, 90.0))
|
||||
.add_enabled_ui(stops.get(sel).is_some() && selected_marker.is_none(), |ui| {
|
||||
widgets::exact_text_field_named(ui, "tabs-leader", Some(tl!("Leader:")), &leader, 90.0)
|
||||
})
|
||||
.inner;
|
||||
if let (Some(leader), Some(s)) = (typed, stops.get_mut(sel)) {
|
||||
// Up to eight characters repeat in the leader.
|
||||
@@ -179,7 +371,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label(egui::RichText::new(tl!("Align On:")).color(t.text));
|
||||
let decimal = stops.get(sel).is_some_and(|s| s.align == TabAlign::Decimal);
|
||||
let decimal = selected_marker.is_none() && stops.get(sel).is_some_and(|s| s.align == TabAlign::Decimal);
|
||||
let on = stops.get(sel).map(|s| s.align_on.to_string()).unwrap_or_else(|| ".".into());
|
||||
let typed = ui.add_enabled_ui(decimal, |ui| widgets::exact_text_field_named(ui, "tabs-align-on", Some(tl!("Align On:")), &on, 30.0)).inner;
|
||||
if let (Some(c), Some(s)) = (typed.and_then(|t| t.chars().next()), stops.get_mut(sel)) {
|
||||
@@ -226,9 +418,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// or Shift held, it lands on the ruler's nearest tick, which can be a fraction of a point.
|
||||
let snap = pstate::<bool>(ui.ctx(), "tabs-snap") || ui.input(|i| i.modifiers.shift);
|
||||
let place = |x: f32| if snap { unit.snap_to_ruler_tick(to_pt(x), scale).max(0.0) } else { to_pt(x).round() };
|
||||
let stored_drag: Option<(IndentMarker, bool)> = pstate(ui.ctx(), "tabs-marker-drag");
|
||||
let marker_drag = stored_drag.filter(|(marker, _)| !mixed_left || *marker != IndentMarker::Lower);
|
||||
if marker_drag != stored_drag {
|
||||
set_pstate::<Option<(IndentMarker, bool)>>(ui.ctx(), "tabs-marker-drag", None);
|
||||
}
|
||||
let preview = marker_drag.and_then(|(marker, both)| {
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos())?;
|
||||
let value = bounded_marker_position(marker, place(pointer.x), span, &targets, both)?;
|
||||
Some((marker, value, both))
|
||||
});
|
||||
paint_markers(ui, &p, &t, MarkerPaint { rect, span, para: ¶, mixed_left, selected: selected_marker, drag: marker_drag, preview }, to_x);
|
||||
for (i, s) in stops.iter().enumerate() {
|
||||
let x = to_x(s.position);
|
||||
let c = if i == sel { t.accent } else { t.text };
|
||||
let c = if i == sel && selected_marker.is_none() { t.accent } else { t.text };
|
||||
let y = rect.bottom() - 2.0;
|
||||
// An original marker: a stem with a foot showing which way the text runs.
|
||||
p.line_segment([egui::pos2(x, y - 12.0), egui::pos2(x, y)], Stroke::new(1.5, c));
|
||||
@@ -245,18 +448,39 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let drag: Option<usize> = pstate(ui.ctx(), "tabs-drag");
|
||||
// The stop within 5 px of `x`.
|
||||
let hit_at = |x: f32| stops.iter().position(|s| (to_x(s.position) - x).abs() <= 5.0);
|
||||
let cancel_drag = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if cancel_drag || (marker_drag.is_some() && !resp.drag_stopped() && !ui.input(|i| i.pointer.any_down())) {
|
||||
set_pstate::<Option<(IndentMarker, bool)>>(ui.ctx(), "tabs-marker-drag", None);
|
||||
set_pstate::<Option<usize>>(ui.ctx(), "tabs-drag", None);
|
||||
}
|
||||
// egui reports the drag only once the pointer is a few pixels from the press, past the hit
|
||||
// tolerance, so hit-test where the button went down (#1458).
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
let hit = hit_at(pos.x);
|
||||
set_pstate(ui.ctx(), "tabs-drag", hit);
|
||||
if let Some(i) = hit {
|
||||
set_pstate(ui.ctx(), "tabs-sel", i);
|
||||
if let Some(marker) = hit_marker(pos, rect, ¶, span, mixed_left, to_x) {
|
||||
let both = marker == IndentMarker::Lower && ui.input(|i| i.modifiers.command);
|
||||
set_pstate(ui.ctx(), "tabs-marker-sel", Some(marker));
|
||||
set_pstate(ui.ctx(), "tabs-marker-drag", Some((marker, both)));
|
||||
set_pstate::<Option<usize>>(ui.ctx(), "tabs-drag", None);
|
||||
} else {
|
||||
let hit = hit_at(pos.x);
|
||||
set_pstate(ui.ctx(), "tabs-drag", hit);
|
||||
if let Some(i) = hit {
|
||||
set_pstate::<Option<IndentMarker>>(ui.ctx(), "tabs-marker-sel", None);
|
||||
set_pstate(ui.ctx(), "tabs-sel", i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& !cancel_drag
|
||||
&& let Some((marker, both)) = marker_drag
|
||||
{
|
||||
set_pstate::<Option<(IndentMarker, bool)>>(ui.ctx(), "tabs-marker-drag", None);
|
||||
if let Some(pos) = resp.interact_pointer_pos() {
|
||||
set_marker(app, marker, place(pos.x), ¶, span, &targets, both);
|
||||
}
|
||||
} else if resp.drag_stopped()
|
||||
&& let (Some(i), Some(pos)) = (drag, resp.interact_pointer_pos())
|
||||
{
|
||||
set_pstate::<Option<usize>>(ui.ctx(), "tabs-drag", None);
|
||||
@@ -272,21 +496,29 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
} else if resp.clicked()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
match hit_at(pos.x) {
|
||||
Some(i) => set_pstate(ui.ctx(), "tabs-sel", i),
|
||||
None => {
|
||||
let position = place(pos.x);
|
||||
// A click that lands on a stop's whole point or tick selects that stop and records no
|
||||
// undo step.
|
||||
let added = !stops.iter().any(|s| s.position == position);
|
||||
if added {
|
||||
stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' });
|
||||
stops.sort_by(|a, b| a.position.total_cmp(&b.position));
|
||||
if let Some(marker) = hit_marker(pos, rect, ¶, span, mixed_left, to_x) {
|
||||
set_pstate(ui.ctx(), "tabs-marker-sel", Some(marker));
|
||||
} else {
|
||||
match hit_at(pos.x) {
|
||||
Some(i) => {
|
||||
set_pstate::<Option<IndentMarker>>(ui.ctx(), "tabs-marker-sel", None);
|
||||
set_pstate(ui.ctx(), "tabs-sel", i);
|
||||
}
|
||||
let i = stops.iter().position(|s| s.position == position).unwrap_or(0);
|
||||
set_pstate(ui.ctx(), "tabs-sel", i);
|
||||
if added {
|
||||
apply(app, &stops);
|
||||
None => {
|
||||
let position = place(pos.x);
|
||||
// A click that lands on a stop's whole point or tick selects that stop and records no
|
||||
// undo step.
|
||||
let added = !stops.iter().any(|s| s.position == position);
|
||||
if added {
|
||||
stops.push(TabStop { position, align: ALIGNS[align_idx.min(3)].0, leader: String::new(), align_on: '.' });
|
||||
stops.sort_by(|a, b| a.position.total_cmp(&b.position));
|
||||
}
|
||||
let i = stops.iter().position(|s| s.position == position).unwrap_or(0);
|
||||
set_pstate::<Option<IndentMarker>>(ui.ctx(), "tabs-marker-sel", None);
|
||||
set_pstate(ui.ctx(), "tabs-sel", i);
|
||||
if added {
|
||||
apply(app, &stops);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +533,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
};
|
||||
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.
|
||||
let mut sel: usize = pstate(ui.ctx(), "tabs-sel");
|
||||
if sel >= stops.len() {
|
||||
@@ -311,13 +544,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
if menu_item(ui, tl!("Delete Tab"), sel < stops.len(), false) {
|
||||
if menu_item(ui, tl!("Delete Tab"), sel < stops.len() && selected_marker.is_none(), false) {
|
||||
stops.remove(sel);
|
||||
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.
|
||||
if menu_item(ui, tl!("Repeat Tab"), sel < stops.len(), false) {
|
||||
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(),
|
||||
_ => stops[sel].position - stops[sel - 1].position,
|
||||
|
||||
@@ -249,6 +249,244 @@ fn drag_on_ruler(ctx: &egui::Context, app: &mut VectorcraftApp, from: f64, to: f
|
||||
ruler_frame(ctx, app, vec![], shift);
|
||||
}
|
||||
|
||||
fn indent_app(left: f64, first: f64) -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("text.create", json!({"x": 10, "y": 20, "text": "one\ntwo", "area": {"width": 300, "height": 100}})).unwrap();
|
||||
app.run("text.setFormat", json!({"leftIndent": left, "firstLineIndent": first})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn indent_values(app: &VectorcraftApp, paragraph: usize) -> (f64, f64) {
|
||||
let (node, _, _) = current(app).unwrap();
|
||||
let NodeKind::Text(t) = node.kind else { panic!("selected text") };
|
||||
let p = t.para_at(paragraph);
|
||||
(p.left_indent, p.first_line_indent)
|
||||
}
|
||||
|
||||
fn marker_event(ctx: &egui::Context, app: &mut VectorcraftApp, from: f64, to: f64, upper: bool, command: bool, shift: bool) {
|
||||
let labels = ruler_frame(ctx, app, vec![], shift);
|
||||
let [(zero, a), (next, b), ..] = &labels[..] else { panic!("ruler labels {labels:?}") };
|
||||
assert_eq!(zero, "0");
|
||||
let per = next.parse::<f64>().unwrap() * app.session.general_unit().points();
|
||||
let x = |pt: f64| a.x - 2.0 + (pt / per) as f32 * (b.x - a.x);
|
||||
let y = a.y + if upper { 3.0 } else { 11.0 };
|
||||
let (press, end) = (egui::pos2(x(from), y), egui::pos2(x(to), y));
|
||||
let modifiers = match (command, shift) {
|
||||
(true, true) => egui::Modifiers { command: true, shift: true, ..Default::default() },
|
||||
(true, false) => egui::Modifiers::COMMAND,
|
||||
(false, true) => egui::Modifiers::SHIFT,
|
||||
(false, false) => egui::Modifiers::NONE,
|
||||
};
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
ruler_frame(ctx, app, vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(press), button(press, true)], shift);
|
||||
if press != end {
|
||||
for f in [0.25, 0.5, 1.0] {
|
||||
ruler_frame(ctx, app, vec![egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(press.lerp(end, f))], shift);
|
||||
}
|
||||
}
|
||||
ruler_frame(ctx, app, vec![egui::Event::ModifiersChanged(modifiers), button(end, false)], shift);
|
||||
ruler_frame(ctx, app, vec![], shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_markers_show_positions_and_select_without_adding_stops() {
|
||||
let mut app = indent_app(24.0, 12.0);
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(marker_position(IndentMarker::Lower, ¤t_para(&app).unwrap().1), 24.0);
|
||||
assert_eq!(marker_position(IndentMarker::Upper, ¤t_para(&app).unwrap().1), 36.0);
|
||||
let labels = ruler_frame(&ctx, &mut app, vec![], false);
|
||||
let [(zero, a), (next, b), ..] = &labels[..] else { panic!("ruler labels {labels:?}") };
|
||||
assert_eq!(zero, "0");
|
||||
let per = next.parse::<f64>().unwrap() * app.session.general_unit().points();
|
||||
let x = |pt: f64| a.x - 2.0 + (pt / per) as f32 * (b.x - a.x);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| show(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let triangles: Vec<egui::Pos2> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Path(path) if path.points.len() == 3 && path.points.iter().all(|p| p.y >= a.y && p.y <= a.y + 16.0) => {
|
||||
Some(egui::pos2(path.points.iter().map(|p| p.x).sum::<f32>() / 3.0, path.points.iter().map(|p| p.y).sum::<f32>() / 3.0))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(triangles.iter().any(|p| (p.x - x(36.0)).abs() < 0.5 && p.y < a.y + 8.0), "upper marker at 36 pt: {triangles:?}");
|
||||
assert!(triangles.iter().any(|p| (p.x - x(24.0)).abs() < 0.5 && p.y > a.y + 8.0), "lower marker at 24 pt: {triangles:?}");
|
||||
marker_event(&ctx, &mut app, 36.0, 36.0, true, false, false);
|
||||
assert_eq!(pstate::<Option<IndentMarker>>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Upper));
|
||||
assert!(current(&app).unwrap().1.is_empty());
|
||||
marker_event(&ctx, &mut app, 24.0, 24.0, false, false, false);
|
||||
assert_eq!(pstate::<Option<IndentMarker>>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Lower));
|
||||
assert!(current(&app).unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_and_lower_marker_drags_map_indents_and_undo_once() {
|
||||
let mut app = indent_app(24.0, 12.0);
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().history.undo.len();
|
||||
marker_event(&ctx, &mut app, 36.0, 60.0, true, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 36.0));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 12.0));
|
||||
marker_event(&ctx, &mut app, 24.0, 40.0, false, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (40.0, -4.0));
|
||||
marker_event(&ctx, &mut app, 40.0, 50.0, false, true, false);
|
||||
assert_eq!(indent_values(&app, 0), (50.0, -4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_shift_drag_snaps_and_coincident_halves_remain_separate() {
|
||||
let mut app = indent_app(24.0, 0.0);
|
||||
let ctx = egui::Context::default();
|
||||
marker_event(&ctx, &mut app, 24.0, 33.4, true, false, true);
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 11.0));
|
||||
app.run("text.setFormat", json!({"leftIndent": 24, "firstLineIndent": 0})).unwrap();
|
||||
marker_event(&ctx, &mut app, 24.0, 40.0, false, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (40.0, -16.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_indent_upper_marker_keeps_each_left_indent_in_one_undo_step() {
|
||||
let mut app = indent_app(24.0, 12.0);
|
||||
let id = current(&app).unwrap().0.id.0;
|
||||
app.run("text.setFormat", json!({"id": id, "start": 4, "end": 4, "leftIndent": 60, "firstLineIndent": -10})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().history.undo.len();
|
||||
marker_event(&ctx, &mut app, 36.0, 70.0, true, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 46.0));
|
||||
assert_eq!(indent_values(&app, 1), (60.0, 10.0));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 12.0));
|
||||
assert_eq!(indent_values(&app, 1), (60.0, -10.0));
|
||||
marker_event(&ctx, &mut app, 60.0, 60.0, false, false, false);
|
||||
assert_ne!(pstate::<Option<IndentMarker>>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Lower));
|
||||
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let a = app.run("text.create", json!({"x": 10, "y": 20, "text": "one", "area": {"width": 300, "height": 100}})).unwrap()["id"].clone();
|
||||
let b = app.run("text.create", json!({"x": 10, "y": 140, "text": "two", "area": {"width": 300, "height": 100}})).unwrap()["id"].clone();
|
||||
app.run("text.setFormat", json!({"id": a, "leftIndent": 24, "firstLineIndent": 12})).unwrap();
|
||||
app.run("text.setFormat", json!({"id": b, "leftIndent": 60, "firstLineIndent": -10})).unwrap();
|
||||
app.run("select.set", json!({"ids": [a, b.clone()]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(current_para(&app).unwrap().1.left_indent, 24.0);
|
||||
marker_event(&ctx, &mut app, 36.0, 70.0, true, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 46.0));
|
||||
let second = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(b.as_u64().unwrap())).unwrap();
|
||||
let NodeKind::Text(second) = &second.kind else { panic!("second text") };
|
||||
assert_eq!((second.para_at(0).left_indent, second.para_at(0).first_line_indent), (60.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_marker_keeps_each_first_line_position_in_one_undo_step() {
|
||||
let mut app = indent_app(24.0, 12.0);
|
||||
let id = current(&app).unwrap().0.id.0;
|
||||
app.run("text.setFormat", json!({"id": id, "start": 4, "end": 7, "firstLineIndent": 28})).unwrap();
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 12.0));
|
||||
assert_eq!(indent_values(&app, 1), (24.0, 28.0));
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().history.undo.len();
|
||||
marker_event(&ctx, &mut app, 24.0, 40.0, false, false, false);
|
||||
assert_eq!(indent_values(&app, 0), (40.0, -4.0));
|
||||
assert_eq!(indent_values(&app, 1), (40.0, 12.0));
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), before + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 12.0));
|
||||
assert_eq!(indent_values(&app, 1), (24.0, 28.0));
|
||||
marker_event(&ctx, &mut app, 24.0, 40.0, false, true, false);
|
||||
assert_eq!(indent_values(&app, 0), (40.0, 12.0));
|
||||
assert_eq!(indent_values(&app, 1), (40.0, 28.0));
|
||||
}
|
||||
|
||||
#[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 }];
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_lower_selection_returns_x_field_to_tab_stop() {
|
||||
let mut app = indent_app(24.0, 12.0);
|
||||
app.run("text.tabs.set", json!({"stops": [{"position": 100}]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
marker_event(&ctx, &mut app, 24.0, 24.0, false, false, false);
|
||||
assert_eq!(pstate::<Option<IndentMarker>>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Lower));
|
||||
let id = current(&app).unwrap().0.id.0;
|
||||
app.run("text.setFormat", json!({"id": id, "start": 4, "end": 7, "leftIndent": 60})).unwrap();
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 12.0));
|
||||
assert_eq!(indent_values(&app, 1), (60.0, 12.0));
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0));
|
||||
let frame = |app: &mut VectorcraftApp, events| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let texts = frame(&mut app, vec![]);
|
||||
let at = texts.iter().find(|(text, _)| text == "100 pt").unwrap_or_else(|| panic!("tab X field missing: {texts:?}")).1.center();
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
frame(&mut app, vec![button(false)]);
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
|
||||
frame(&mut app, vec![egui::Event::Text("120".into())]);
|
||||
frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
assert_eq!(indent_values(&app, 0).0, 24.0);
|
||||
assert_eq!(indent_values(&app, 1).0, 60.0);
|
||||
assert_eq!(current(&app).unwrap().1[0].position, 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_marker_position_field_sets_its_absolute_position() {
|
||||
let mut app = indent_app(24.0, 12.0);
|
||||
let ctx = egui::Context::default();
|
||||
marker_event(&ctx, &mut app, 36.0, 36.0, true, false, false);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 400.0));
|
||||
let field_frame = |app: &mut VectorcraftApp, events| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let texts = field_frame(&mut app, vec![]);
|
||||
let at = texts.iter().find(|(text, _)| text == "36 pt").unwrap_or_else(|| panic!("36 pt in {texts:?}")).1.center();
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
field_frame(&mut app, vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
field_frame(&mut app, vec![button(false)]);
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
field_frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
|
||||
field_frame(&mut app, vec![egui::Event::Text("80".into())]);
|
||||
field_frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
assert_eq!(indent_values(&app, 0), (24.0, 56.0));
|
||||
marker_event(&ctx, &mut app, 24.0, 24.0, false, false, false);
|
||||
let texts = field_frame(&mut app, vec![]);
|
||||
let at = texts.iter().find(|(text, _)| text == "24 pt").unwrap_or_else(|| panic!("24 pt in {texts:?}")).1.center();
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
field_frame(&mut app, vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
field_frame(&mut app, vec![button(false)]);
|
||||
field_frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
|
||||
field_frame(&mut app, vec![egui::Event::Text("40".into())]);
|
||||
field_frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
assert_eq!(indent_values(&app, 0), (40.0, 40.0));
|
||||
}
|
||||
|
||||
/// The ruler is numbered in the General unit (the document's units), with labels at least 50 px
|
||||
/// apart as on the canvas rulers.
|
||||
#[test]
|
||||
@@ -550,6 +788,22 @@ fn panel_menu_repeats_deletes_and_clears_tab_stops() {
|
||||
assert_eq!(positions(&app).len(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_menu_does_not_change_stops_while_an_indent_marker_is_selected() {
|
||||
let mut app = indent_app(24.0, 12.0);
|
||||
app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 70}]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "tabs-sel", 1usize);
|
||||
marker_event(&ctx, &mut app, 36.0, 36.0, true, false, false);
|
||||
assert_eq!(pstate::<Option<IndentMarker>>(&ctx, "tabs-marker-sel"), Some(IndentMarker::Upper));
|
||||
let before = current(&app).unwrap().1;
|
||||
|
||||
choose(&ctx, &mut app, "Repeat Tab");
|
||||
assert_eq!(current(&app).unwrap().1, before);
|
||||
choose(&ctx, &mut app, "Delete Tab");
|
||||
assert_eq!(current(&app).unwrap().1, before);
|
||||
}
|
||||
|
||||
/// Repeat Tab on the first stop spaces the copies by the stop's distance from the paragraph's
|
||||
/// left indent, from the paragraph the caret is in while the Type tool edits text.
|
||||
#[test]
|
||||
|
||||
@@ -855,6 +855,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X.
|
||||
The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`).
|
||||
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.
|
||||
- **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).
|
||||
|
||||
Reference in New Issue
Block a user