Merge pull request #553: land #533, #548, #549, #552

Land #533, #548, #549, #552 with review fixes
This commit is contained in:
BFlat
2026-10-09 01:08:30 +07:00
committed by GitHub
16 changed files with 268 additions and 65 deletions
+6 -5
View File
File diff suppressed because one or more lines are too long
+28 -9
View File
@@ -4,9 +4,9 @@
//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, and the power
//! preference ([`power_preference`]) among the rest. eframe is handed [`selector`], which takes
//! the first of that order.
//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, the power preference
//! ([`power_preference`]) among the rest, and a GPU's DX12 adapter before its Vulkan one (#545).
//! eframe is handed [`selector`], which takes the first of that order.
//!
//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
@@ -89,15 +89,17 @@ fn key(backend: Backend, vendor: u32, device: u32) -> String {
/// The order to try `candidates` in (their indices): only those that present to the window and
/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
/// software; native backends before OpenGL; then by `power`, keeping the system's order among
/// equals (all of it for [`PowerPreference::None`]).
/// software; native backends before OpenGL; then by `power`; then DX12 before Vulkan, keeping the
/// system's order among equals (all of it for [`PowerPreference::None`]). Windows lists each GPU
/// under both, and Intel's Vulkan driver made the whole window flicker black where DX12 and OpenGL
/// didn't (#545); elsewhere there is no DX12 adapter, so the order is unchanged.
pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec<usize> {
let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
// Stable, so equals keep the system's order.
order.sort_by_key(|(_, c)| {
let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power))
(unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power), c.backend == Backend::Vulkan)
});
order.into_iter().map(|(i, _)| i).collect()
}
@@ -361,7 +363,7 @@ mod tests {
}
/// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
/// integrated GPU first, as wgpu's own choice did (#306).
/// integrated GPU first, as wgpu's own choice did (#306), each GPU through DX12 first (#545).
#[test]
fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() {
let c = vec![
@@ -372,8 +374,25 @@ mod tests {
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
];
let order = adapter_order(&c, PowerPreference::LowPower, None, &[]);
assert_eq!(order, [1, 3, 0, 2, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [0, 2, 1, 3, 4]);
assert_eq!(order, [3, 1, 2, 0, 4]);
assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [2, 0, 3, 1, 4]);
}
/// The machine of #545: one Intel GPU, whose Vulkan driver made the window flicker black.
/// DX12 comes first whatever the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME`
/// or a restart leaving DX12 out still reach it.
#[test]
fn a_windows_gpu_renders_through_dx12_before_vulkan() {
let c = vec![
gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu),
gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other),
];
for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] {
assert_eq!(adapter_order(&c, power, None, &[]), [1, 0, 3, 2], "{power:?}");
}
assert_eq!(adapter_order(&c, PowerPreference::LowPower, None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
}
#[test]
+9 -2
View File
@@ -174,6 +174,9 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon
}
}
/// The room the Control bar's inline X/Y/W/H fields need; with less, only the Transform link shows.
const INLINE_TRANSFORM_WIDTH: f32 = 440.0;
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -280,9 +283,13 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
ui.separator();
// Transform fields.
// The Transform link opens the whole Transform panel (reference point, rotate,
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room.
crate::panels::transform::link(app, ui);
// The bounding box, rotated with rotated objects: its centre and its own sides.
if let Some(b) = app.selection_box() {
if let Some(b) = app.selection_box()
&& ui.available_width() >= INLINE_TRANSFORM_WIDTH
{
let c = b.center();
let link = app.session.prefs.constrain_proportions;
for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] {
+1 -2
View File
@@ -143,6 +143,7 @@
Make Pixel Perfect Zarovnat na obrazové body
Slice Řez
Make Vytvořit
Make and Expand Vytvořit a rozložit
Release Uvolnit
Create from Guides Vytvořit z vodítek
Create from Selection Vytvořit z výběru
@@ -199,8 +200,6 @@
Merge Sloučit
Gap Options… Možnosti mezer…
Image Trace Vektorizace obrazu
Make… Vytvořit…
Make and Expand… Vytvořit a rozložit…
Text Wrap Obtékání textu
Text Wrap Options… Možnosti obtékání textu…
Clipping Mask Ořezová maska
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1 -2
View File
@@ -1369,7 +1369,6 @@
Make a theme on Create and save it. Crea un tema en Crear y guárdalo.
Make all bleed settings the same Usar el mismo valor en todos los sangrados
Make and Expand Crear y expandir
Make and Expand… Crear y expandir…
Make Clipping Mask Crear máscara de recorte
Make Compound Shape Crear forma compuesta
Make Envelope Crear envolvente
@@ -1382,7 +1381,6 @@
Make with Top Object Crear con objeto superior
Make with Warp… Crear con deformación…
Make/Release Clipping Mask Crear/soltar máscara de recorte
Make… Crear…
Manage Workspaces Administrar espacios de trabajo
Manage Workspaces… Administrar espacios de trabajo…
Manually Manualmente
@@ -2546,6 +2544,7 @@
Character: Carácter:
Character options Opciones de carácter
Stroke options Opciones de trazo
Transform options Opciones de transformación
Stroke Weight Grosor de trazo
Stroke: Trazo:
Stroke: click for swatches, Shift-click for the color mixer Trazo: clic para muestras, Shift+clic para el mezclador de colores
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1 -2
View File
@@ -1369,7 +1369,6 @@
Make a theme on Create and save it. Crea un tema nella scheda Crea e salvalo.
Make all bleed settings the same Usa lo stesso valore per tutte le abbondanze
Make and Expand Crea ed espandi
Make and Expand… Crea ed espandi…
Make Clipping Mask Crea maschera di ritaglio
Make Compound Shape Crea forma composta
Make Envelope Crea involucro
@@ -1382,7 +1381,6 @@
Make with Top Object Crea con oggetto superiore
Make with Warp… Crea con alterazione…
Make/Release Clipping Mask Crea/rilascia maschera di ritaglio
Make… Crea…
Manage Workspaces Gestisci aree di lavoro
Manage Workspaces… Gestisci aree di lavoro…
Manually Manualmente
@@ -2546,6 +2544,7 @@
Character: Carattere:
Character options Opzioni carattere
Stroke options Opzioni traccia
Transform options Opzioni di trasformazione
Stroke Weight Spessore traccia
Stroke: Traccia:
Stroke: click for swatches, Shift-click for the color mixer Traccia: fai clic per i campioni, Shift+clic per il miscelatore colori
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1 -2
View File
@@ -233,8 +233,6 @@
Image Trace 画像トレース
Image Tracing 画像トレースオブジェクト
Tracing Presets トレースプリセット
Make… 作成…
Make and Expand… 作成して拡張…
Text Wrap テキストの回り込み
Text Wrap Options… テキストの回り込みオプション…
Clipping Mask クリッピングマスク
@@ -2352,6 +2350,7 @@
Character: 文字:
Character options 文字オプション
Stroke options 線オプション
Transform options 変形オプション
Stroke: 線:
Style スタイル
Style Name: スタイル名:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1 -2
View File
@@ -1242,13 +1242,11 @@
Make a theme on Create and save it. Crie um tema em Criar e salve-o.
Make all bleed settings the same Tornar todas as configurações de sangria iguais
Make and Expand Criar e Expandir
Make and Expand… Criar e Expandir…
Make the bleed values differ Fazer os valores de sangria diferirem
Make with Mesh… Criar com Malha…
Make with Top Object Criar com Objeto Superior
Make with Warp… Criar com Distorção…
Make/Release Clipping Mask Criar/Liberar Máscara de Recorte
Make… Criar…
Manage Workspaces Gerenciar Espaços de Trabalho
Manage Workspaces… Gerenciar Espaços de Trabalho…
Manually Manualmente
@@ -2248,6 +2246,7 @@
Character: Caractere:
Character options Opções de caractere
Stroke options Opções de traçado
Transform options Opções de transformação
Stroke: Traçado:
Style Estilo
Style Name: Nome do Estilo:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -2
View File
@@ -1351,7 +1351,6 @@
Make a theme on Create and save it. 在“创建”中制作主题并存储。
Make all bleed settings the same 使所有出血设置相同
Make and Expand 创建并扩展
Make and Expand… 制作并展开…
Make Clipping Mask 制作剪切蒙版
Make Compound Shape 创建复合形状
Make Envelope 制作封套
@@ -1364,7 +1363,6 @@
Make with Top Object 用顶层对象创建
Make with Warp… 用变形创建…
Make/Release Clipping Mask 创建/释放剪切蒙版
Make… 制作…
Manage Workspaces 管理工作区
Manage Workspaces… 管理工作区…
Manually 手动
@@ -2484,6 +2482,7 @@
Character: 字符:
Character options 字符选项
Stroke options 描边选项
Transform options 变换选项
Stroke Weight 描边粗细
Stroke: 描边:
Stroke: click for swatches, Shift-click for the color mixer 描边:单击开启色板,按住 Shift 单击开启混色器
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1 -2
View File
@@ -1387,7 +1387,6 @@
Make a theme on Create and save it. 按一下「建立」即可製作主題並儲存。
Make all bleed settings the same 將所有出血設定設為相同
Make and Expand 製作並展開
Make and Expand… 製作並展開…
Make Clipping Mask 製作剪裁遮色片
Make Compound Shape 製作複合形狀
Make Envelope 製作封套
@@ -1400,7 +1399,6 @@
Make with Top Object 以最上層物件製作
Make with Warp… 以彎曲製作…
Make/Release Clipping Mask 製作/釋放剪裁遮色片
Make… 製作…
Manage Workspaces 管理工作區
Manage Workspaces… 管理工作區…
Manually 手動
@@ -2566,6 +2564,7 @@
Character: 字元:
Character options 字元選項
Stroke options 筆畫選項
Transform options 變形選項
Stroke Weight 筆畫寬度
Stroke: 筆畫:
Stroke: click for swatches, Shift-click for the color mixer 筆畫:按一下開啟色票,按住 Shift 按一下開啟混色器
Can't render this file because it contains an unexpected character in line 6 and column 47.
+69 -25
View File
@@ -2157,8 +2157,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
sub(
"Image Trace",
vec![
cp("Make…", "imageTrace.make", json!({"preset": "Black and White Logo"})),
cp("Make and Expand…", "imageTrace.makeAndExpand", json!({"preset": "6 Colors"})),
// Traced at once with the Default preset, as the Control bar's Image Trace
// button does; other presets come from its arrow or the Image Trace panel
// (#543: the "…" form asked for a preset's name in a text box).
cp("Make", "imageTrace.make", json!({"preset": "Default"})),
cp("Make and Expand", "imageTrace.makeAndExpand", json!({"preset": "Default"})),
c("Release", "imageTrace.release"),
c("Expand", "imageTrace.expand"),
],
@@ -2661,9 +2664,24 @@ pub fn ruler_menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, clicked: &mut Op
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
let t = Tokens::get(ui.ctx());
let ctx = ui.ctx().clone();
// The press that opens a menu takes the keyboard from a focused text field: the field and its
// selection are kept while the menus are open, so their Select All, Cut, Copy and Paste act on
// its text (#516), the frame after the click that chose them (that click takes the keyboard
// from it again).
type Field = (egui::Id, egui::text_edit::TextEditState);
let (field_key, chosen_key) = (egui::Id::new("menu-bar-field"), egui::Id::new("menu-bar-field-event"));
if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) {
ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key));
state.store(&ctx, field);
to_field(&ctx, field, e);
}
if let Some(field) = focused_text_field(&ctx).and_then(|id| Some((id, egui::TextEdit::load_state(&ctx, id)?))) {
ctx.data_mut(|d| d.insert_temp::<Field>(field_key, field));
}
let mut clicked: Option<(String, Value)> = None;
let tree = menu_tree();
let end = egui::MenuBar::new()
let (end, open) = egui::MenuBar::new()
.ui(ui, |ui| {
let mut titles = Vec::with_capacity(tree.len());
for (i, (title, items)) in tree.iter().enumerate() {
@@ -2674,12 +2692,21 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
};
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
}
switch_on_hover(ui.ctx(), &titles);
ui.cursor().min.x
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
})
.inner;
let field = ctx.data(|d| d.get_temp::<Field>(field_key));
if !open && focused_text_field(&ctx).is_none() {
ctx.data_mut(|d| d.remove::<Field>(field_key));
}
if let Some((id, p)) = clicked {
invoke(app, &id, p);
match field.and_then(|f| Some((f, field_event(app, &id)?))) {
Some(chosen) => {
ctx.data_mut(|d| d.insert_temp(chosen_key, chosen));
ctx.request_repaint();
}
None => invoke(app, &id, p),
}
}
end
}
@@ -2692,29 +2719,25 @@ fn pointer_reaches_title(ctx: &egui::Context, title: &egui::Response, p: egui::P
}
/// Like a native menu bar: while one top-level menu is open, moving the pointer onto another
/// title opens that menu instead (egui alone needs a click on each title).
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) {
/// title opens that menu instead (egui alone needs a click on each title). Returns whether a menu
/// is open.
fn switch_on_hover(ctx: &egui::Context, titles: &[egui::Response]) -> bool {
let ids: Vec<egui::Id> = titles.iter().map(egui::Popup::default_response_id).collect();
let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else {
return;
return false;
};
// `Response::hovered` is false while a menu's popup is open, so hit-test the titles here.
let Some(p) = ctx.pointer_hover_pos() else {
return;
};
// Only a moving pointer switches: one resting on a title leaves the open menu alone.
if ctx.input(|i| i.pointer.delta() == egui::Vec2::ZERO) {
return;
}
let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p)) else {
return;
};
if i != open
let moving = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
if let Some(p) = ctx.pointer_hover_pos().filter(|_| moving)
&& let Some(i) = titles.iter().position(|title| pointer_reaches_title(ctx, title, p))
&& i != open
&& let Some(id) = ids.get(i)
{
egui::Popup::open_id(ctx, *id);
ctx.request_repaint();
}
true
}
/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt;
@@ -2859,13 +2882,21 @@ fn field_event(app: &mut VectorcraftApp, id: &str) -> Option<egui::Event> {
/// Copy and Paste act on the field's text, as their keys do; everything else (and those commands
/// with no field focused) goes to [`invoke`].
pub fn invoke_from_system_menu(app: &mut VectorcraftApp, ctx: &egui::Context, id: &str, p: Value) {
if ctx.text_edit_focused()
&& let Some(e) = field_event(app, id)
{
ctx.input_mut(|i| i.events.push(e));
return;
match focused_text_field(ctx).and_then(|f| Some((f, field_event(app, id)?))) {
Some((field, e)) => to_field(ctx, field, e),
None => invoke(app, id, p),
}
invoke(app, id, p);
}
/// The text field that has the keyboard, if any.
fn focused_text_field(ctx: &egui::Context) -> Option<egui::Id> {
ctx.memory(|m| m.focused()).filter(|&id| egui::TextEdit::load_state(ctx, id).is_some())
}
/// Give text field `field` the keyboard (back) and event `e`, a [`field_event`], this frame.
fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
ctx.memory_mut(|m| m.request_focus(field));
ctx.input_mut(|i| i.events.push(e));
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
@@ -3752,6 +3783,19 @@ mod tests {
assert_eq!(pretty_shortcut(""), "");
}
/// #543: Object › Image Trace › Make and Make and Expand trace with the Default preset at once,
/// with no form asking for a preset's name.
#[test]
fn image_trace_make_traces_with_the_default_preset() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
for cmd in ["imageTrace.make", "imageTrace.makeAndExpand"] {
let e = menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some(cmd)).unwrap();
assert_eq!(e.path, ["Object", "Image Trace"]);
assert_eq!(click_target(&e.label, cmd, &e.params), (cmd.to_string(), json!({"preset": "Default"})), "{}", e.label);
}
}
#[test]
fn expand_appearance_is_an_object_menu_item_only() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
+16 -1
View File
@@ -13,6 +13,8 @@ use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
pub const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0];
/// The shear angle's presets: steep enough to be useful, short of the degenerate ±90°.
pub const SHEAR_PRESETS: [f64; 9] = [-60.0, -45.0, -30.0, -15.0, 0.0, 15.0, 30.0, 45.0, 60.0];
/// Proportional size: the other dimension when one changes with the link on.
pub fn constrained(w: f64, h: f64, new_w: Option<f64>, new_h: Option<f64>) -> (f64, f64) {
@@ -151,8 +153,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(4.0);
let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
crate::scrub::note_label(ui, icon.rect);
if let Some(a) = widgets::plain_field(ui, "xfp-shear", 0.0, "°", 1, 56.0)
// Shear is relative: the field rests at 0° and a new value shears by it.
if let Some(a) = widgets::spin_plain(ui, "xfp-shear", 0.0, "°", 2, 96.0, 15.0, -89.0, &SHEAR_PRESETS)
&& a != 0.0
&& a.abs() < 90.0
{
app.run("object.shear", json!({"angle": a, "axis": "horizontal", "origin": origin})).ok();
}
@@ -206,6 +210,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
/// popover under it, as the Stroke link does with the Stroke panel.
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let resp = ui.link(egui::RichText::new(tl!("Transform")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Transform options"));
widgets::popover(&resp, resp.clicked(), |ui| {
ui.set_width(300.0);
show(app, ui);
});
}
/// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the
/// `constrainProportions` preference, which the size fields pass on as `proportional`.
pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) {
+28
View File
@@ -242,3 +242,31 @@ fn the_window_menu_panel_keys() {
app.ui.shortcut_overrides.clear();
shortcut_editor::sync(&app.ui);
}
/// Where `shapes` paint the text `s`.
fn text_rect(shapes: &[Shape], s: &str) -> Option<Rect> {
shapes.iter().find_map(|sh| match sh {
Shape::Text(t) if t.galley.text() == s => Some(t.visual_bounding_rect()),
Shape::Vec(v) => text_rect(v, s),
_ => None,
})
}
#[test]
fn the_transform_link_opens_the_transform_panel() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
let ctx = context();
frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
let link = text_rect(&shapes, "Transform").expect("the Control bar shows the Transform link");
assert!(text_rect(&shapes, "Scale Corners").is_none());
click(&mut app, &ctx, link.center(), Modifiers::NONE, control_bar);
let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
for s in ["Scale Corners", "Scale Strokes & Effects", "0°"] {
assert!(text_rect(&shapes, s).is_some(), "the popover shows {s}");
}
// A click outside closes it.
click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar);
assert!(text_rect(&frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar), "Scale Corners").is_none());
}
+91
View File
@@ -171,3 +171,94 @@ fn cmd_a_on_the_canvas_selects_all_objects() {
a.menu("select.all");
assert_eq!(a.selected(), 2, "so does the menu's Select All");
}
/// The copied texts of a frame's output.
fn copied(out: &egui::FullOutput) -> Vec<String> {
out.platform_output.commands.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None }).collect()
}
/// #516: Ctrl/Cmd+C, X and V in a focused field (the window's own clipboard keys, which egui sends
/// as Copy, Cut and Paste events) edit the field's text, never the art.
#[test]
fn the_clipboard_keys_in_a_focused_field_edit_its_text() {
let mut a = App::new();
a.app.run("window.panel", json!({"panel": "color"})).unwrap();
for _ in 0..3 {
a.frame(vec![]);
}
for text in ["37 pt", "FFFFFF"] {
let (id, rect) = a.field(text);
a.frame(vec![Event::PointerMoved(rect.center()), click(rect.center(), true)]);
a.frame(vec![click(rect.center(), false)]);
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
assert!(a.ctx.memory(|m| m.has_focus(id)), "{text}: the field has the keyboard");
let out = a.frame(vec![Event::Copy]);
assert_eq!(copied(&out), [text], "Copy copies the field's text");
assert!(a.app.session.clipboard.is_empty(), "{text}: no art copied");
let out = a.frame(vec![Event::Cut]);
assert_eq!(copied(&out), [text], "Cut cuts the field's text");
a.frame(vec![Event::Paste("12".into())]);
assert_eq!(a.ctx.data(|d| d.get_temp::<String>(id)).as_deref(), Some("12"), "Paste pastes into the field");
assert_eq!(a.selected(), 1, "{text}: the art stays");
a.frame(vec![press(Key::Escape, Modifiers::NONE)]);
}
}
/// The centre of the text `label` painted last (in the topmost layer) in a frame's output; with
/// `title`, the first one painted (the menu bar's).
fn text_at(out: &egui::FullOutput, label: &str, title: bool) -> Pos2 {
let mut shapes: Box<dyn Iterator<Item = _>> = if title { Box::new(out.shapes.iter()) } else { Box::new(out.shapes.iter().rev()) };
shapes
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text().trim() == label => Some(Rect::from_min_size(t.pos, t.galley.size()).center()),
_ => None,
})
.unwrap_or_else(|| panic!("no {label:?} painted"))
}
/// A click on `at` → the frame after it.
fn click_at(a: &mut App, at: Pos2) -> egui::FullOutput {
a.frame(vec![Event::PointerMoved(at), click(at, true)]);
a.frame(vec![click(at, false)]);
a.frame(vec![])
}
/// The in-window menus' Select › All, Edit › Copy and Edit › Paste act on the text field that had the keyboard
/// when the menu was opened (the click on the menu took it), and give it the keyboard back.
#[test]
fn the_in_window_edit_menu_acts_on_the_field_it_was_opened_from() {
let mut a = App::new();
// Text on the system clipboard.
a.app.services.clipboard_read = Some(Box::new(|| Some("42 pt".into())));
let (id, rect) = a.field("37 pt");
a.frame(vec![Event::PointerMoved(rect.center()), click(rect.center(), true)]);
a.frame(vec![click(rect.center(), false)]);
let out = a.frame(vec![press(Key::End, Modifiers::NONE)]);
let (edit, select) = (text_at(&out, "Edit", true), text_at(&out, "Select", true));
let menu = |a: &mut App, title: Pos2, item: &str| {
let out = click_at(a, title);
assert!(!a.ctx.text_edit_focused(), "the menu took the keyboard");
click_at(a, text_at(&out, item, false))
};
menu(&mut a, select, "All");
assert!(a.ctx.memory(|m| m.has_focus(id)), "the field has the keyboard back");
assert_eq!(a.field_selection(id), Some((0, 5)), "Select All selected the field's text");
assert_eq!(a.selected(), 1, "not the art");
a.frame(vec![press(Key::End, Modifiers::NONE)]);
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
a.frame(vec![]);
let out = menu(&mut a, edit, "Copy");
assert_eq!(copied(&out), ["37 pt"], "Copy copied the field's text");
assert!(a.app.session.clipboard.is_empty(), "no art copied");
// Art on the app's clipboard (Paste is enabled): the field still takes the text.
a.app.run("edit.copy", json!({})).unwrap();
a.frame(vec![press(Key::A, Modifiers::COMMAND)]);
menu(&mut a, edit, "Paste");
assert_eq!(a.ctx.data(|d| d.get_temp::<String>(id)).as_deref(), Some("42 pt"), "Paste pasted into the field");
assert_eq!(a.selected(), 1, "nothing pasted on the canvas");
// With no field focused, the menu's Select All selects the art.
a.frame(vec![press(Key::Escape, Modifiers::NONE)]);
a.frame(vec![]);
menu(&mut a, select, "All");
assert_eq!(a.selected(), 2);
}
+13 -8
View File
@@ -806,18 +806,23 @@ pub fn opacity_blend(
edit
}
/// The 3×3 reference point locator. Returns a new index when clicked.
/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around
/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked.
pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let size = 22.0;
// 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through
// the squares' centres stay crisp.
let (sq, step) = (5.0, 9.0);
let size = 2.0 * step + sq;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let o = rect.min.round();
let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step);
let line = Stroke::new(1.0, t.text_dim);
ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle);
let mut out = None;
let step = size / 2.0 - 2.5;
ui.painter().rect_stroke(rect.shrink(4.5), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Middle);
for i in 0..9 {
let c = Pos2::new(rect.left() + 3.0 + (i % 3) as f32 * step + 1.5, rect.top() + 3.0 + (i / 3) as f32 * step + 1.5);
let r = Rect::from_center_size(c, Vec2::splat(5.0));
let resp = ui.interact(r.expand(1.5), ui.id().with(("refpt", i)), Sense::click());
let r = Rect::from_center_size(centre(i), Vec2::splat(sq));
let resp = ui.interact(r.expand(2.0), ui.id().with(("refpt", i)), Sense::click());
if resp.clicked() {
out = Some(i);
}
@@ -825,7 +830,7 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
ui.painter().rect_filled(r, 0.0, t.text);
} else {
ui.painter().rect_filled(r, 0.0, t.panel);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside);
}
}
out
+1 -1
View File
@@ -38,7 +38,7 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; then the power preference, keeping the system's order among equals.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; then the power preference; then, on Windows, where each GPU is listed under both, a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals. `WGPU_BACKEND=vulkan` still picks Vulkan.
- **Preferences › Performance › Graphics Processor** (`gpuPreference`): `automatic` (the default), `lowPower` (Power Saving, the integrated GPU) or `highPerformance` (the discrete GPU). The adapter is chosen when the window opens, so a change applies after a restart.
- **Automatic on Windows and macOS** is power saving: the system shows frames from any GPU, and presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some hybrid laptops (#306).
- **Automatic on Linux and the BSDs** keeps the system's order: Mesa's Vulkan device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid laptops; `DRI_PRIME` and `MESA_VK_DEVICE_SELECT` steer it). A Wayland compositor may not accept frames from another GPU: on a desktop whose compositor ran on an NVIDIA GPU, rendering on the Ryzen's integrated GPU made KWin end the window's connection ("importing the supplied dmabufs failed") and 0.5.0 crashed at startup (#502).