Fix #729: the Tabs panel's Above Text puts its ruler over the text

The Tabs panel's ruler was a fixed strip in the panel, at no relation to
the text on the canvas, so stops couldn't be placed by eye. Above Text
(Position Panel Above Text) floats the panel just above the selected
text, as wide as its ruler needs to put 0 on the text's left edge and
its marks at the canvas zoom: each stop sits over the column it sets.
Floating panel groups keep a width they are given.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-09 16:38:49 +07:00
1 parent 008b5e6d6c
commit c16804237e
11 files changed
+97 -13

No files matched your search

+1 -1
View File
@@ -184,7 +184,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info; AF.1–AF.3 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish | 3–6 (existing scope; Affinity export unestimated) |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel, inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, per-paragraph paragraph attributes, Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
+4 -3
View File
@@ -129,7 +129,7 @@ pub fn float(ui: &mut UiState, ids: &[&str], active: &str, pos: Pos2) {
}
let panels: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
let active = panels.iter().position(|p| p == active).unwrap_or(0);
ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y] });
ui.floating_panels.push(FloatingPanels { panels, active, pos: [pos.x, pos.y], width: None });
// The tabbed group shows another of its tabs when the one it showed floats.
if let Some(tab) = shown_tab(ui) {
ui.dock_tab = tab;
@@ -312,6 +312,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let ids = ids_of(g);
let Some(&first) = ids.first() else { continue };
let active = ids.get(g.active).copied().unwrap_or(first);
let given = g.width.map(|w| w.min(screen.width() - 16.0));
let area = area_id(first);
let size = ctx.memory(|m| m.area_rect(area)).map_or(vec2(dock::panel_width(active), 200.0), |r| r.size());
let pos = clamp(g.pos, size, screen);
@@ -333,7 +334,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
})
.collect();
let tabs_width: f32 = tabs.iter().map(|g| g.size().x + 24.0).sum();
let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max);
let width = ids.iter().map(|id| dock::panel_width(id)).fold(tabs_width + 32.0, f32::max).max(given.unwrap_or(0.0));
ui.set_width(width);
// Title bar: a grip that moves the group, and the × that docks it.
let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover());
@@ -763,7 +764,7 @@ mod tests {
}))
.unwrap();
let edited = edited.sanitized();
assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0] }]);
assert_eq!(edited.floating_panels, [FloatingPanels { panels: vec!["layers".into()], active: 0, pos: [1.0, 2.0], width: None }]);
h.settle();
let g = h.group("layers");
let screen = Rect::from_min_size(Pos2::ZERO, SCREEN);
+2
View File
@@ -339,6 +339,8 @@
Clean Up… Limpiar…
Clear Borrar
Clear All Tabs Borrar todas las tabulaciones
Above Text Sobre el texto
Position Panel Above Text Colocar el panel sobre el texto
Clear Appearance Borrar apariencia
Clear Guides Borrar guías
Clear Overrides Borrar modificaciones
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -340,6 +340,8 @@
Clean Up… Nettoyer…
Clear Effacer
Clear All Tabs Effacer toutes les tabulations
Above Text Au-dessus du texte
Position Panel Above Text Placer le panneau au-dessus du texte
Clear Appearance Effacer l’aspect
Clear Guides Effacer les repères
Clear Overrides Effacer les remplacements
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -339,6 +339,8 @@
Clean Up… Pulisci…
Clear Cancella
Clear All Tabs Cancella tutte le tabulazioni
Above Text Sopra il testo
Position Panel Above Text Posiziona il pannello sopra il testo
Clear Appearance Cancella aspetto
Clear Guides Cancella guide
Clear Overrides Cancella sostituzioni
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -882,6 +882,8 @@
Choose… 選択…
Circle 円
Clear All Tabs すべてのタブを消去
Above Text テキストの上
Position Panel Above Text テキストの上にパネルを配置
Clear Appearance アピアランスを消去
Clear Overrides オーバーライドを消去
Click クリック
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -337,6 +337,8 @@
Clean Up… Очистить…
Clear Очистить
Clear All Tabs Удалить все табуляции
Above Text Над текстом
Position Panel Above Text Расположить панель над текстом
Clear Appearance Очистить оформление
Clear Guides Удалить направляющие
Clear Overrides Очистить переопределения
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -325,6 +325,8 @@
Clean Up… 清理…
Clear 清除
Clear All Tabs 清除全部制表位
Above Text 文本上方
Position Panel Above Text 将面板置于文本上方
Clear Appearance 清除外观
Clear Guides 清除参考线
Clear Overrides 清除覆盖
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -337,6 +337,8 @@
Clean Up… 清除…
Clear 清除
Clear All Tabs 清除所有定位點
Above Text 文字上方
Position Panel Above Text 將面板置於文字上方
Clear Appearance 清除外觀
Clear Guides 清除參考線
Clear Overrides 清除覆寫
Can't render this file because it contains an unexpected character in line 6 and column 47.
+75 -9
View File
@@ -3,7 +3,7 @@
use egui::{Sense, Stroke, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{NodeKind, TabAlign, TabStop};
use vectorcraft_doc::{Node, NodeKind, TabAlign, TabStop, TextKind};
use super::{first_selected, pstate, set_pstate};
use crate::VectorcraftApp;
@@ -26,9 +26,13 @@ fn align_id(a: TabAlign) -> &'static str {
}
}
/// 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).
fn current(app: &VectorcraftApp) -> Option<(Vec<TabStop>, f64)> {
/// 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).
fn current(app: &VectorcraftApp) -> Option<(Node, Vec<TabStop>, f64)> {
let editing = super::character::text_editing(app);
let n = match editing {
Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(),
@@ -37,10 +41,39 @@ fn current(app: &VectorcraftApp) -> Option<(Vec<TabStop>, f64)> {
let NodeKind::Text(t) = &n.kind else { return None };
let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start);
let span = match &t.kind {
vectorcraft_doc::TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
TextKind::Area { frame } => frame.bounds().map_or(360.0, |b| b.width()),
_ => 360.0,
};
Some((t.para_at(para).tabs.clone(), span.max(72.0)))
let tabs = t.para_at(para).tabs.clone();
Some((n, tabs, span.max(72.0)))
}
/// Position Panel Above Text: float the panel just above text `n`, as wide as its ruler needs to
/// put 0 on the text's left edge and its marks at the canvas zoom, so each stop sits over the
/// column it sets (#729). `span`: the ruler's length in the text's points.
fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64) -> Option<()> {
let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?);
let on_screen = xf.rect_to_screen(n.geometric_bounds()?);
// Screen points per point of the text: across the frame for area type (so a scaled frame
// lines up too), the zoom for point type.
let per = match &n.kind {
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => on_screen.width() / span as f32,
_ => xf.zoom as f32,
};
if !per.is_finite() || per <= 0.0 {
return None;
}
let width = span as f32 * per + RULER_LEFT * 2.0;
let height = ctx.memory(|m| m.area_rect(crate::floating::area_id("tabs"))).map_or(240.0, |r| r.height());
let pos = egui::pos2(on_screen.left() - RULER_LEFT, on_screen.top() - height - 6.0);
if crate::floating::group_of(&app.ui, "tabs").is_none() {
crate::floating::float(&mut app.ui, &["tabs"], "tabs", pos);
}
let gi = crate::floating::group_of(&app.ui, "tabs")?;
let g = app.ui.floating_panels.get_mut(gi)?;
g.pos = [pos.x, pos.y];
g.width = Some(width);
Some(())
}
fn stops_json(stops: &[TabStop]) -> Value {
@@ -63,7 +96,7 @@ fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some((mut stops, span)) = current(app) else {
let Some((node, mut stops, span)) = current(app) else {
super::empty_state(ui, "pilcrow", tl!("No text selected"), tl!("Select a text object to set its tab stops."));
return;
};
@@ -89,6 +122,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(tl!("Above Text")).on_hover_text(tl!("Position Panel Above Text")).clicked() {
above_text(app, ui.ctx(), &node, span);
}
});
});
ui.add_space(4.0);
egui::Grid::new("tabs-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
@@ -209,7 +247,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((mut stops, _)) = current(app) else {
let Some((_, mut stops, _)) = current(app) else {
ui.add_enabled(false, egui::Button::new(tl!("Select text")).frame(false));
return;
};
@@ -259,11 +297,39 @@ mod tests {
frame(&mut app);
app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50}, {"position": 120, "align": "decimal"}]})).unwrap();
frame(&mut app);
let (stops, span) = current(&app).unwrap();
let (_, stops, span) = current(&app).unwrap();
assert_eq!(stops.len(), 2);
assert_eq!(span, 300.0);
// The stops survive a JSON round trip through the panel's encoding.
let back: Vec<serde_json::Value> = stops_json(&stops).as_array().unwrap().clone();
assert_eq!(back[1]["align"], json!("decimal"));
}
/// Position Panel Above Text floats the panel over the text, its ruler's 0 on the frame's left
/// edge and a stop's mark over the column it sets, at any zoom (#729).
#[test]
fn above_text_puts_the_ruler_over_the_text_at_the_zoom() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
app.session.execute("text.create", &json!({"x": 40, "y": 200, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0)));
for zoom in [1.0, 2.0] {
if let Some(v) = app.view_mut() {
v.zoom = zoom;
}
let (node, _, span) = current(&app).unwrap();
let ctx = egui::Context::default();
above_text(&mut app, &ctx, &node, span).unwrap();
let g = &app.ui.floating_panels[crate::floating::group_of(&app.ui, "tabs").unwrap()];
let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let frame = xf.rect_to_screen(node.geometric_bounds().unwrap());
assert!((g.pos[0] + RULER_LEFT - frame.left()).abs() < 0.01, "0 on the frame's left edge");
assert!(g.pos[1] < frame.top(), "above the text");
// The ruler (the panel less its margins) spans the frame at this zoom.
let ruler = g.width.unwrap() - RULER_LEFT * 2.0;
assert!((ruler - frame.width()).abs() < 0.01, "{ruler} {}", frame.width());
assert!((ruler - 300.0 * zoom as f32).abs() < 0.01);
}
assert_eq!(app.ui.floating_panels.len(), 1, "placed again, not floated twice");
}
}
+3
View File
@@ -425,6 +425,9 @@ pub struct FloatingPanels {
pub active: usize,
/// Top-left corner in screen points.
pub pos: [f32; 2],
/// A width the group was given (the Tabs panel sized over its text), else its panels' own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<f32>,
}
impl FloatingPanels {