mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 18:15:25 +00:00
Tabs: the ruler shows when a non-text object is first in the selection
With the Selection tool, the Tabs panel showed "No text selected" when the first selected object was a shape, or a group holding text, even with text selected. It now shows the ruler and stops of the first paragraph of the first selected text object, looking into selected groups as the Character and Paragraph panels do. A stop set on that ruler goes to every selected text object, as text.tabs.set already did; other objects are left as they were. A selection without text still shows the empty state.
This commit is contained in:
@@ -42,14 +42,21 @@ pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft
|
||||
|
||||
/// The first selected text descendant, shared by Character and Paragraph controls.
|
||||
pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> {
|
||||
match &first_selected_text_node(app)?.kind {
|
||||
NodeKind::Text(t) => Some(t.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The node of [`first_selected_text`]: the first text object among the selected objects and
|
||||
/// their descendants, in selection order, then paint order within each selected object.
|
||||
pub(crate) fn first_selected_text_node(app: &VectorcraftApp) -> Option<&vectorcraft_doc::Node> {
|
||||
let st = app.session.active()?;
|
||||
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
let mut text = None;
|
||||
node.walk(&mut |n| {
|
||||
if text.is_none()
|
||||
&& let NodeKind::Text(t) = &n.kind
|
||||
{
|
||||
text = Some(t.as_ref());
|
||||
if text.is_none() && matches!(n.kind, NodeKind::Text(_)) {
|
||||
text = Some(n);
|
||||
}
|
||||
});
|
||||
if text.is_some() {
|
||||
|
||||
@@ -10,7 +10,7 @@ use egui::{Sense, Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use super::{pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
@@ -330,12 +330,14 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec<TabStop>, f64)> {
|
||||
|
||||
/// 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.
|
||||
/// the ruler shows it. Without the Type tool editing text, the panel shows the first paragraph of
|
||||
/// the first selected text object, also when that text is in a selected group or follows a
|
||||
/// selected object that is not text.
|
||||
fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> {
|
||||
let editing = super::character::text_editing(app);
|
||||
let n = match editing {
|
||||
Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(),
|
||||
None => first_selected(app)?,
|
||||
None => super::character::first_selected_text_node(app)?.clone(),
|
||||
};
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start);
|
||||
|
||||
@@ -27,6 +27,83 @@ fn panel_draws_empty_with_text_and_with_stops() {
|
||||
assert_eq!(back[1]["align"], json!("decimal"));
|
||||
}
|
||||
|
||||
/// A rectangle and two area texts, the first with a stop at 40 pt and the second with one at
|
||||
/// 90 pt, nothing selected. → the app and the ids of the rectangle and the two texts.
|
||||
fn mixed_selection_app() -> (VectorcraftApp, [u64; 3]) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let id = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let rect = id(app.run("shape.rectangle", json!({"x": 10, "y": 200, "width": 40, "height": 40})).unwrap());
|
||||
let a = id(app.run("text.create", json!({"x": 10, "y": 20, "text": "a\tb", "area": {"width": 300, "height": 60}})).unwrap());
|
||||
let b = id(app.run("text.create", json!({"x": 10, "y": 100, "text": "c\td", "area": {"width": 250, "height": 60}})).unwrap());
|
||||
for (text, position) in [(a, 40), (b, 90)] {
|
||||
app.run("select.set", json!({"ids": [text]})).unwrap();
|
||||
app.run("text.tabs.set", json!({"stops": [{"position": position}]})).unwrap();
|
||||
}
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
(app, [rect, a, b])
|
||||
}
|
||||
|
||||
/// With a rectangle selected first and a text after it, the panel shows the text's stops on its
|
||||
/// ruler, not the "No text selected" state.
|
||||
#[test]
|
||||
fn the_ruler_shows_text_selected_after_an_object_that_is_not_text() {
|
||||
let (mut app, [rect, a, _]) = mixed_selection_app();
|
||||
app.run("select.set", json!({"ids": [rect, a]})).unwrap();
|
||||
let (node, stops, span) = current(&app).unwrap();
|
||||
assert_eq!(node.id.0, a);
|
||||
assert_eq!(stops.iter().map(|s| s.position).collect::<Vec<_>>(), [40.0]);
|
||||
assert_eq!(span, 300.0);
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0"));
|
||||
}
|
||||
|
||||
/// With a group selected first whose text follows a rectangle in it, the panel shows that text's
|
||||
/// stops, ahead of a text selected after the group.
|
||||
#[test]
|
||||
fn the_ruler_shows_text_inside_a_selected_group() {
|
||||
let (mut app, [rect, a, b]) = mixed_selection_app();
|
||||
app.run("select.set", json!({"ids": [rect, b]})).unwrap();
|
||||
let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [group, a]})).unwrap();
|
||||
let (node, stops, span) = current(&app).unwrap();
|
||||
assert_eq!(node.id.0, b);
|
||||
assert_eq!(stops.iter().map(|s| s.position).collect::<Vec<_>>(), [90.0]);
|
||||
assert_eq!(span, 250.0);
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0"));
|
||||
}
|
||||
|
||||
/// A stop added on the ruler of a mixed selection sets the first text's stops plus the new one on
|
||||
/// every selected text, and leaves the rectangle as it was.
|
||||
#[test]
|
||||
fn a_stop_added_on_a_mixed_selection_sets_every_selected_text() {
|
||||
let (mut app, [rect, a, b]) = mixed_selection_app();
|
||||
let shape = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().clone();
|
||||
let before = shape(&app);
|
||||
app.run("select.set", json!({"ids": [rect, a, b]})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
drag_on_ruler(&ctx, &mut app, 60.0, 60.0, false);
|
||||
for text in [a, b] {
|
||||
assert_eq!(text_of(&app, text).para_at(0).tabs.iter().map(|s| s.position).collect::<Vec<_>>(), [40.0, 60.0], "text {text}");
|
||||
}
|
||||
assert_eq!(shape(&app), before);
|
||||
}
|
||||
|
||||
/// A selection without text keeps the "No text selected" state and draws no ruler.
|
||||
#[test]
|
||||
fn a_selection_without_text_shows_no_ruler() {
|
||||
let (mut app, [rect, ..]) = mixed_selection_app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 200, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [rect, id]})).unwrap();
|
||||
let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||||
assert!(current(&app).is_none());
|
||||
let ctx = egui::Context::default();
|
||||
assert!(ruler_labels(&ctx, &mut app).is_empty());
|
||||
assert!(crate::tests_labels::painted_text(&mut app, show).contains("No text selected"));
|
||||
}
|
||||
|
||||
/// In vertical type the Left and Right alignment buttons are named Top-Justified Tab and
|
||||
/// Bottom-Justified Tab, in their tool tips and for screen readers, and the position field is
|
||||
/// labeled Y. Horizontal type keeps Left-Justified Tab, Right-Justified Tab and X.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user