Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 8c69173bb7 #1089: an image copied in a browser pastes as the picture, also from its markup
A browser's Copy Image puts the picture on the clipboard with text next
to it. Paste already took the picture when that text was only the
image's address (#597), but some browsers (seen on Fedora with KDE) put
the image's HTML there instead, `<img src="…" class="…" style="…">`,
which isn't an address, so the markup was pasted as type.

names_a_picture also takes markup whose only content is <img> elements
(no text outside its tags) as naming the picture, and Paste then reads
the bitmap. Markup with words in it, and an address alone, still paste
as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:51:41 +07:00
BFlat c7d29e5b92 Merge pull request #1134 from storytold/fix/1129-toolbar-proxy-size
#1129: the toolbar's paint buttons fit its width
2026-10-11 13:51:27 +07:00
bflatasticandClaude Opus 5.5 b9aca50c3c #1129: the toolbar's paint buttons fit its width
The Fill/Stroke proxy stayed 36 pt and the Color, Gradient and None
buttons 7.5 pt in both toolbar layouts, so in two columns they sat small
in a wide strip and were hard to hit (in one column too). The proxy is
now 54 pt in two columns, the three buttons 11 pt in one column and
17 pt in two, and in two columns Draw Normal, Draw Behind and Draw
Inside sit side by side, each setting its mode (view.drawMode {mode}),
as Illustrator's two-column toolbar shows them; one column keeps the
button that cycles them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:26:24 +07:00
6 changed files with 106 additions and 20 deletions

No files matched your search

+1 -1
View File
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
+19 -1
View File
@@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect;
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address};
pub use flavours::{BITMAP, EMF, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub(crate) use resources::SwatchChoices;
use super::*;
@@ -248,6 +248,24 @@ mod tests {
}
}
/// The image markup some browsers put as text next to a copied picture names it too (#1089);
/// markup with words in it is text.
#[test]
fn markup_of_only_images_names_a_picture() {
for m in [
r#"<img src="https://static.example.com/eagle.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#,
r#"<meta http-equiv="content-type" content="text/html; charset=utf-8"><img src="a.png">"#,
"<html><body><!--StartFragment--><IMG SRC=\"a.png\"/><!--EndFragment--></body></html>",
r#"<a href="https://x.org"><img src="a.png"></a>"#,
"https://example.com/cat.png",
] {
assert!(names_a_picture(m), "{m}");
}
for t in ["<p>Hello</p>", r#"<img src="a.png"> and a caption"#, "<b>bold</b>", "<imgx>", "<img", "plain words", "<br>", ""] {
assert!(!names_a_picture(t), "{t}");
}
}
#[test]
fn copy_exports_svg_that_round_trips_through_paste() {
let mut s = session();
@@ -39,6 +39,30 @@ pub fn is_address(text: &str) -> bool {
!t.chars().any(char::is_whitespace) && SCHEMES.iter().any(|s| t.get(..s.len()).is_some_and(|h| h.eq_ignore_ascii_case(s)) && t.len() > s.len())
}
/// Is `text` only a reference to a picture: an address ([`is_address`]), or markup that shows
/// nothing but images (`<img …>`)? A browser's Copy Image puts one of them next to the picture
/// (some put the image's HTML there as text, #1089): then the picture is what Paste takes.
pub fn names_a_picture(text: &str) -> bool {
is_address(text) || only_images(text)
}
/// Is `html` markup whose only content is `<img>` elements: no text outside its tags (whitespace
/// between them aside), and at least one image?
fn only_images(html: &str) -> bool {
let mut images = 0;
let mut rest = html.trim();
while !rest.is_empty() {
let Some(tag) = rest.strip_prefix('<') else { return false };
let Some(end) = tag.find('>') else { return false };
let name = tag.get(..end).unwrap_or_default().split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default();
if name.eq_ignore_ascii_case("img") {
images += 1;
}
rest = tag.get(end + 1..).unwrap_or_default().trim_start();
}
images > 0
}
/// How much of a copied file [`file_flavour`] needs to tell what it is.
pub const FILE_HEAD: usize = 4096;
+4 -3
View File
@@ -8,7 +8,7 @@
//! reads from a paste itself ([`VectorcraftApp::paste_from_host`]).
use serde_json::{Value, json};
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, is_address, looks_like_svg};
use vectorcraft_engine::cmd::clipboard::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};
use crate::VectorcraftApp;
@@ -90,8 +90,9 @@ impl VectorcraftApp {
return Ok(());
}
let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) };
// Text that is only an address goes with a browser's copied picture: the picture.
let f = if f.mime == TEXT && is_address(&String::from_utf8_lossy(&f.data)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f };
// Text that only names a picture (its address, or its image markup) goes with a
// browser's copied picture: the picture.
let f = if f.mime == TEXT && names_a_picture(&String::from_utf8_lossy(&f.data)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f };
import_command(&f, center)
}
// Text only: SVG markup is art, other text is left alone.
+7 -1
View File
@@ -168,7 +168,8 @@ fn text_from_another_app_pastes_as_point_text_and_svg_text_as_art() {
}
/// A browser's Copy Image: the picture with its address as text (and HTML, which Paste doesn't
/// read). The picture is pasted; an address alone pastes as text, nothing is fetched (#597).
/// read), or its markup as text. The picture is pasted; an address alone pastes as text, nothing
/// is fetched (#597, #1089).
#[test]
fn an_image_copied_in_a_browser_pastes_as_the_picture_not_its_address() {
let (mut app, board) = app();
@@ -177,6 +178,11 @@ fn an_image_copied_in_a_browser_pastes_as_the_picture_not_its_address() {
run(&mut app, "edit.paste", json!({}));
let NodeKind::Image(im) = &pasted(&app)[0].kind else { panic!("not an image: {:?}", pasted(&app)[0].kind) };
assert!(im.link.is_none() && (im.width, im.height) == (4, 2));
// Some browsers (on Linux) put the image's markup there as its text: the picture still (#1089).
let markup = br#"<img src="https://example.com/images/cat.png" jsaction="" class="sFlh5c" style="max-width: 351px" alt="">"#.to_vec();
copy_elsewhere(&board, vec![(TEXT, markup), (PNG, blue_png())]);
run(&mut app, "edit.paste", json!({}));
assert!(matches!(pasted(&app)[0].kind, NodeKind::Image(_)), "{:?}", pasted(&app)[0].kind);
copy_elsewhere(&board, vec![(TEXT, url)]);
run(&mut app, "edit.paste", json!({}));
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") };
+51 -14
View File
@@ -253,7 +253,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t);
bottom_controls(app, ui, &t, cols);
});
}
@@ -419,16 +419,21 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.separator();
}
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
/// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as
/// large as the toolbar's `cols` (one or two) leave room for (#1129): in two columns the proxy and
/// buttons grow and the three drawing modes show side by side.
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usize) {
let two = cols == 2;
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, if two { 54.0 } else { 36.0 }));
ui.add_space(5.0);
// Color (the last solid colour), Gradient (the last gradient) and None, as commands.
let (chip, gap) = if two { (17.0, 4.0) } else { (11.0, 2.0) };
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 27.0) / 2.0);
ui.spacing_mut().item_spacing.x = 2.0;
ui.add_space((ui.available_width() - 3.0 * chip - 2.0 * gap) / 2.0);
ui.spacing_mut().item_spacing.x = gap;
let mut clicked = None;
for (tip, cmd) in [("Color (,)", "paint.lastColor"), ("Gradient (.)", "paint.lastGradient"), ("None (/)", "paint.none")] {
let (r, resp) = ui.allocate_exact_size(vec2(7.5, 7.5), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(chip, chip), Sense::click());
match cmd {
"paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)),
"paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient.gradient),
@@ -443,15 +448,28 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
}
});
ui.add_space(6.0);
let modes = [
(vectorcraft_engine::DrawMode::Normal, "dc-draw-normal", "Draw Normal", "normal"),
(vectorcraft_engine::DrawMode::Behind, "dc-draw-behind", "Draw Behind", "behind"),
(vectorcraft_engine::DrawMode::Inside, "dc-draw-inside", "Draw Inside", "inside"),
];
if two {
// Side by side, each one a click away.
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 3.0 * 22.0) / 2.0);
for (mode, icon, name, id) in modes {
if widgets::icon_button(ui, icon, &format!("{} (Shift+D)", tl!(name)), app.session.draw_mode == mode, 22.0).clicked() {
app.run("view.drawMode", json!({ "mode": id })).ok();
}
}
});
}
ui.vertical_centered(|ui| {
let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"];
let names = ["Draw Normal", "Draw Behind", "Draw Inside"];
let m = match app.session.draw_mode {
vectorcraft_engine::DrawMode::Normal => 0,
vectorcraft_engine::DrawMode::Behind => 1,
vectorcraft_engine::DrawMode::Inside => 2,
};
if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", tl!(names[m])), m != 0, 26.0).clicked() {
// One column: the current mode's button, which cycles them.
if !two
&& let Some((mode, icon, name, _)) = modes.into_iter().find(|m| m.0 == app.session.draw_mode)
&& widgets::icon_button(ui, icon, &format!("{} (Shift+D)", tl!(name)), mode != vectorcraft_engine::DrawMode::Normal, 26.0).clicked()
{
app.run("view.drawMode", json!({})).ok();
}
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
@@ -1105,6 +1123,25 @@ pub(crate) mod tests {
frame_in(app, ctx, time + 0.1, vec![], height)
}
/// The Color, Gradient and None buttons grow with the toolbar, and in two columns the three
/// drawing modes sit side by side, each setting its mode (#1129).
#[test]
fn the_paint_and_drawing_mode_buttons_fit_the_toolbar_width() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap();
let ctx = egui::Context::default();
frame_in(&mut app, &ctx, 0.0, vec![], 900.0);
assert_eq!(widget_rects(&ctx, vec2(11.0, 11.0)).len(), 3, "one column: three 11 pt chips");
app.ui.toolbar_double = true;
frame_in(&mut app, &ctx, 1.0, vec![], 900.0);
assert_eq!(widget_rects(&ctx, vec2(17.0, 17.0)).len(), 3, "two columns: three 17 pt chips");
let modes = widget_rects(&ctx, vec2(22.0, 22.0));
assert_eq!(modes.len(), 3, "the three drawing modes side by side");
assert!(modes.iter().all(|r| (r.top() - modes[0].top()).abs() < 0.5), "{modes:?}");
click_in(&mut app, &ctx, 2.0, modes[1].center(), PointerButton::Primary, 900.0);
assert_eq!(app.session.draw_mode, vectorcraft_engine::DrawMode::Behind);
}
/// How many columns of tool buttons `rects` make.
fn columns(rects: &[egui::Rect]) -> usize {
let mut lefts: Vec<i32> = rects.iter().map(|r| r.left().round() as i32).collect();