mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-15 03:36:51 -04:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16403a851 | ||
|
|
b64dcc02df | ||
|
|
406c27ebd8 | ||
|
|
dfe7f1a311 |
No files matched your search
@@ -158,6 +158,10 @@ Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/cr
|
||||
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
|
||||
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
|
||||
|
||||
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
|
||||
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
|
||||
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
Register the MCP server with Claude Code:
|
||||
|
||||
+3
-1
@@ -42,7 +42,7 @@ since 2026-10-01.
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); Windows/Linux packaging and accessibility are pending |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -95,6 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
@@ -141,6 +142,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
|
||||
@@ -241,6 +241,27 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The power preference wgpu picks the window's graphics adapter with (#306): the
|
||||
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
|
||||
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
|
||||
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
|
||||
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
|
||||
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
|
||||
/// GPU both preferences pick it.
|
||||
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
|
||||
use eframe::wgpu::PowerPreference;
|
||||
match (env, pref) {
|
||||
(Some(p), _) => p,
|
||||
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
|
||||
(None, _) => PowerPreference::LowPower,
|
||||
}
|
||||
}
|
||||
|
||||
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
|
||||
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
@@ -262,7 +283,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
let options = eframe::NativeOptions {
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
@@ -276,6 +299,9 @@ fn main() -> eframe::Result {
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
..Default::default()
|
||||
};
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
options,
|
||||
@@ -297,6 +323,11 @@ fn main() -> eframe::Result {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let summary = adapter_summary(&rs.adapter.get_info());
|
||||
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
|
||||
app.graphics_adapter = Some(summary);
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
@@ -316,3 +347,35 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::wgpu::PowerPreference;
|
||||
|
||||
#[test]
|
||||
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
|
||||
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
|
||||
// A value this version doesn't know (a newer or damaged preference file) is the default.
|
||||
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
|
||||
// WGPU_POWER_PREF wins over the preference, either way.
|
||||
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
|
||||
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
|
||||
}
|
||||
|
||||
/// The preference the engine saves is the one the app reads back before the window opens.
|
||||
#[test]
|
||||
fn the_saved_engine_preference_is_found() {
|
||||
let mut prefs = vectorcraft_engine::Prefs::default();
|
||||
let saved = prefs.to_json();
|
||||
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
|
||||
prefs.gpu_preference = "highPerformance".into();
|
||||
let saved = prefs.to_json();
|
||||
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ pub const UNITS: &[(&str, &str)] = &[
|
||||
("feet", "Feet"),
|
||||
];
|
||||
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup.
|
||||
pub const GPU_PREFERENCES: &[(&str, &str)] = &[("powerSaving", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
@@ -265,6 +267,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
// Performance
|
||||
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
|
||||
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
|
||||
p!("gpuPreference", "Performance", "GPU Performance", "Graphics Processor", choice(GPU_PREFERENCES)),
|
||||
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
|
||||
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
|
||||
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
|
||||
|
||||
@@ -397,6 +397,12 @@ pub struct Prefs {
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
/// Which graphics processor the desktop app asks for at startup (it takes effect after a
|
||||
/// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance`
|
||||
/// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so
|
||||
/// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one
|
||||
/// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected.
|
||||
pub gpu_preference: String,
|
||||
pub history_states: u32,
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
@@ -598,6 +604,7 @@ impl Default for Prefs {
|
||||
interface_language: s("auto"),
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
gpu_preference: s("powerSaving"),
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
|
||||
@@ -122,3 +122,33 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() {
|
||||
assert_eq!(partial.keyboard_increment, 4.0);
|
||||
assert_eq!(partial.corner_radius, 12.0);
|
||||
}
|
||||
|
||||
/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept
|
||||
/// through a save/load round trip, and a bad value is an error that changes nothing.
|
||||
#[test]
|
||||
fn gpu_preference_defaults_to_power_saving_and_validates() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "highPerformance");
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
|
||||
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
|
||||
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
|
||||
assert_eq!(back.gpu_preference, "highPerformance");
|
||||
// Preference files written before the preference existed get the default.
|
||||
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
|
||||
assert_eq!(old.gpu_preference, "powerSaving");
|
||||
let l = s.execute("prefs.list", &json!({})).unwrap();
|
||||
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
|
||||
assert_eq!(row["category"], "Performance");
|
||||
assert_eq!(row["options"], json!(["powerSaving", "highPerformance"]));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
@@ -83,6 +83,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
|
||||
"activeDocument": app.session.active_index(),
|
||||
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
|
||||
"graphicsAdapter": app.graphics_adapter,
|
||||
// Saves and exports still being written in the background (Background Save / Export).
|
||||
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
|
||||
})
|
||||
|
||||
@@ -37,6 +37,11 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
))
|
||||
.size(11.0),
|
||||
);
|
||||
// The GPU the window renders with, for bug reports (Preferences › Performance picks it).
|
||||
if let Some(adapter) = &app.graphics_adapter {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Graphics: {adapter}"), &[("adapter", adapter.as_str())])).size(11.0));
|
||||
}
|
||||
});
|
||||
app.ui.about = open;
|
||||
}
|
||||
+336
-74
@@ -1,109 +1,200 @@
|
||||
//! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column
|
||||
//! whose panels pop out to the left.
|
||||
//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed
|
||||
//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column
|
||||
//! then expands it again.
|
||||
|
||||
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
|
||||
use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, icons, panels, widgets};
|
||||
|
||||
const ICON_COL: f32 = 38.0;
|
||||
/// Height of the strip along the top of a dock column that carries its double arrow.
|
||||
const HEADER: f32 = 14.0;
|
||||
/// The tabbed group's width until the dock has been laid out (its default size).
|
||||
const DOCK_WIDTH: f32 = 300.0;
|
||||
/// Top of a popped-out panel.
|
||||
const FLYOUT_TOP: f32 = 110.0;
|
||||
|
||||
/// Where the icon column's left edge was drawn last (egui temp memory, one frame old).
|
||||
fn column_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-icon-column-left")
|
||||
}
|
||||
|
||||
/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while
|
||||
/// one of its panels is popped out shows that panel's tab instead.
|
||||
pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
app.ui.dock_collapsed = collapsed;
|
||||
app.ui.dock = true;
|
||||
if !collapsed && let Some(tab) = app.ui.open_panel.as_deref().and_then(DockTab::from_id) {
|
||||
app.ui.dock_tab = tab;
|
||||
app.ui.open_panel = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
|
||||
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
|
||||
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
|
||||
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
|
||||
let arrow = Rect::from_min_size(hdr.right_top() + vec2(-16.0, 1.0), vec2(12.0, 12.0));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(arrow, CornerRadius::same(2), t.hover);
|
||||
}
|
||||
let icon = if collapsed { "chevrons-left" } else { "chevrons-right" };
|
||||
icons::paint(ui, icon, arrow.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text });
|
||||
resp.on_hover_text(if collapsed { tl!("Expand Panels") } else { tl!("Collapse to Icons") }).clicked()
|
||||
}
|
||||
|
||||
/// Run `window.collapseDock` for a click on a double arrow.
|
||||
fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
// It only fails on bad params, which this never sends; show it all the same.
|
||||
if let Err(e) = app.run("window.collapseDock", json!({ "collapsed": collapsed })) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// One icon of the column: a click pops its panel out (or puts it away).
|
||||
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, label: &str, icon: &str) {
|
||||
let open = app.ui.open_panel.as_deref() == Some(id);
|
||||
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Main tabbed group.
|
||||
egui::Panel::right("dock")
|
||||
.resizable(true)
|
||||
.default_size(300.0)
|
||||
.size_range(230.0..=520.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
|
||||
.show(ui, |ui| {
|
||||
// Tab strip.
|
||||
let (hdr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover());
|
||||
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
|
||||
icons::paint(ui, "chevrons-right", egui::Rect::from_min_size(hdr.right_top() + vec2(-14.0, 2.0), vec2(10.0, 10.0)), t.text);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let mut x = strip.left();
|
||||
for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] {
|
||||
let active = app.ui.dock_tab == tab;
|
||||
let galley =
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
// Main tabbed group, unless it is collapsed to icons.
|
||||
if !app.ui.dock_collapsed {
|
||||
egui::Panel::right("dock")
|
||||
.resizable(true)
|
||||
.default_size(DOCK_WIDTH)
|
||||
.size_range(230.0..=520.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
|
||||
.show(ui, |ui| {
|
||||
if collapse_header(ui, false) {
|
||||
toggle(app, true);
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
if resp.clicked() {
|
||||
app.ui.dock_tab = tab;
|
||||
// Tab strip.
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let mut x = strip.left();
|
||||
for tab in DockTab::ALL {
|
||||
let (_, label, _) = tab.info();
|
||||
let active = app.ui.dock_tab == tab;
|
||||
let galley =
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
if resp.clicked() {
|
||||
app.ui.dock_tab = tab;
|
||||
}
|
||||
x = r.right();
|
||||
}
|
||||
x = r.right();
|
||||
}
|
||||
let menu_id = match app.ui.dock_tab {
|
||||
DockTab::Properties => "properties",
|
||||
DockTab::Layers => "layers",
|
||||
DockTab::Libraries => "libraries",
|
||||
};
|
||||
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
|
||||
DockTab::Properties => {
|
||||
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
|
||||
}
|
||||
DockTab::Layers => panels::layers::show(app, ui),
|
||||
DockTab::Libraries => panels::libraries(app, ui),
|
||||
let menu_id = app.ui.dock_tab.info().0;
|
||||
panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)));
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
|
||||
DockTab::Properties => {
|
||||
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
|
||||
}
|
||||
DockTab::Layers => panels::layers::show(app, ui),
|
||||
DockTab::Libraries => panels::libraries(app, ui),
|
||||
});
|
||||
});
|
||||
});
|
||||
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock).
|
||||
egui::Panel::right("icon_column")
|
||||
}
|
||||
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A
|
||||
// collapsed group's panels head it, under the « that expands them again.
|
||||
let collapsed = app.ui.dock_collapsed;
|
||||
let column = egui::Panel::right("icon_column")
|
||||
.resizable(false)
|
||||
.exact_size(ICON_COL)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(4, 6)).stroke(Stroke::new(1.5, t.border)))
|
||||
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
// The icons scroll in a window too short for them.
|
||||
widgets::strip_scroll(ui, "icon_column", |ui| {
|
||||
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
|
||||
if gi > 0 {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
|
||||
ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
|
||||
}
|
||||
for id in group.iter() {
|
||||
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
|
||||
let open = app.ui.open_panel.as_deref() == Some(*id);
|
||||
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
if collapsed && collapse_header(ui, true) {
|
||||
toggle(app, false);
|
||||
}
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
// The icons scroll in a window too short for them.
|
||||
widgets::strip_scroll(ui, "icon_column", |ui| {
|
||||
if collapsed {
|
||||
for tab in DockTab::ALL {
|
||||
let (id, label, icon) = tab.info();
|
||||
panel_icon(app, ui, id, label, icon);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
|
||||
if gi > 0 || collapsed {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
|
||||
ui.painter()
|
||||
.line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
|
||||
}
|
||||
for id in group.iter() {
|
||||
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
|
||||
panel_icon(app, ui, id, label, icon);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let left = column.response.rect.left();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(column_left_id(), left));
|
||||
}
|
||||
|
||||
/// An icon panel popped out next to the icon column.
|
||||
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
|
||||
/// while the dock is collapsed.
|
||||
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.ui.open_panel.clone() else { return };
|
||||
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
|
||||
let tab = DockTab::from_id(&id);
|
||||
let label = match tab {
|
||||
Some(tab) if app.ui.dock_collapsed => tab.info().1,
|
||||
// Expanded, the group shows its panels in the dock itself.
|
||||
Some(_) => return,
|
||||
None => match ICON_PANELS.iter().find(|p| p.0 == id) {
|
||||
Some((_, label, _)) => *label,
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// Pinned by its right edge, 6 points left of the icon column: a panel wider than its 256
|
||||
// points grows towards the canvas rather than over the panel icons.
|
||||
let right = screen.right() - ICON_COL - 300.0 - 6.0;
|
||||
// Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its
|
||||
// collapsed state) put it: content wider than the flyout grows it towards the canvas rather
|
||||
// than over the panel icons. Without a dock on screen it keeps to the window's right edge.
|
||||
let column = if app.ui.dock && app.ui.screen_mode < 3 {
|
||||
ctx.data(|d| d.get_temp::<f32>(column_left_id()))
|
||||
.filter(|x| x.is_finite() && *x > screen.left() && *x <= screen.right())
|
||||
.unwrap_or_else(|| screen.right() - ICON_COL - if app.ui.dock_collapsed { 0.0 } else { DOCK_WIDTH })
|
||||
} else {
|
||||
screen.right()
|
||||
};
|
||||
let right = column - 6.0;
|
||||
// The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one
|
||||
// that keeps the flyout on screen.
|
||||
// The tabbed group's panels keep the width the dock gives them; icon panels are 256 points.
|
||||
let width = if tab.is_some() { DOCK_WIDTH } else { 256.0 };
|
||||
let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0);
|
||||
let mut open = true;
|
||||
let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP);
|
||||
area.fixed_pos(egui::pos2(right, 110.0)).show(ctx, |ui| {
|
||||
area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
|
||||
ui.set_width(256.0);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
|
||||
ui.set_width(width);
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
|
||||
let tab = egui::Rect::from_min_size(
|
||||
let title = egui::Rect::from_min_size(
|
||||
strip.min,
|
||||
vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
|
||||
);
|
||||
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
|
||||
ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
|
||||
ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text);
|
||||
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));
|
||||
let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click());
|
||||
icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim });
|
||||
@@ -113,8 +204,26 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0));
|
||||
panels::panel_menu(app, ui, &id, menu_r);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| {
|
||||
ui.set_width(236.0);
|
||||
panels::show_icon_panel(app, ui, &id);
|
||||
ui.set_width(width - 20.0);
|
||||
match tab {
|
||||
Some(DockTab::Properties) => {
|
||||
// At least `tall` when the sections are taller: the flyout's area keeps the
|
||||
// size its content needs, and egui sizes a new area at most 400 points high,
|
||||
// so a scroll area that only took the room it was given never grew past it.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("props-flyout")
|
||||
.max_height(tall)
|
||||
.min_scrolled_height(tall)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| panels::properties::show(app, ui));
|
||||
}
|
||||
Some(DockTab::Layers) => {
|
||||
ui.set_max_height(tall);
|
||||
panels::layers::show(app, ui);
|
||||
}
|
||||
Some(DockTab::Libraries) => panels::libraries(app, ui),
|
||||
None => panels::show_icon_panel(app, ui, &id),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -179,4 +288,157 @@ mod tests {
|
||||
}
|
||||
assert!(icons.last().unwrap().bottom() <= 400.0, "scrolled into view: {:?}", icons.last());
|
||||
}
|
||||
|
||||
const SCREEN: egui::Vec2 = vec2(1600.0, 1000.0);
|
||||
|
||||
/// A frame of the dock and its flyout on a 1600 × 1000 window.
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut h = Self { app, ctx, time: 0.0 };
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<egui::Event>) {
|
||||
self.time += 0.05;
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN);
|
||||
let input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(screen), ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
self.ctx
|
||||
.run_ui(input, |ui| {
|
||||
show(app, ui);
|
||||
floating_panel(app, ui.ctx());
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..3 {
|
||||
self.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn click(&mut self, at: egui::Pos2) {
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
self.frame(vec![egui::Event::PointerMoved(at), button(true)]);
|
||||
self.frame(vec![button(false)]);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
/// The icon column's left edge as last drawn.
|
||||
fn column_left(&self) -> f32 {
|
||||
self.ctx.data(|d| d.get_temp::<f32>(column_left_id())).unwrap()
|
||||
}
|
||||
|
||||
/// The panel icons of the column, top to bottom.
|
||||
fn icons(&self) -> Vec<Rect> {
|
||||
widget_rects(&self.ctx, vec2(30.0, 30.0))
|
||||
}
|
||||
|
||||
/// The double arrow at the top right of the window (the dock's, or the collapsed column's).
|
||||
fn arrow(&self) -> egui::Pos2 {
|
||||
egui::pos2(SCREEN.x - 10.0, HEADER / 2.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_panel_count() -> usize {
|
||||
ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_double_arrow_collapses_the_dock_to_icons_and_back() {
|
||||
let mut h = Harness::new();
|
||||
assert!(!h.app.ui.dock_collapsed);
|
||||
// Expanded, the group (at least its 230-point minimum) is right of the column.
|
||||
let expanded = |left: f32| left <= SCREEN.x - ICON_COL - 230.0;
|
||||
assert!(expanded(h.column_left()), "expanded: the group is right of the column: {}", h.column_left());
|
||||
assert_eq!(h.icons().len(), icon_panel_count());
|
||||
h.click(h.arrow());
|
||||
assert!(h.app.ui.dock_collapsed, "the » collapses the dock");
|
||||
assert!(h.column_left() > SCREEN.x - ICON_COL - 2.0, "collapsed: only the icon column is left: {}", h.column_left());
|
||||
let icons = h.icons();
|
||||
assert_eq!(icons.len(), icon_panel_count() + 3, "Properties, Layers and Libraries became icons");
|
||||
assert!(icons[0].top() > HEADER, "the icons sit under the « strip");
|
||||
h.click(h.arrow());
|
||||
assert!(!h.app.ui.dock_collapsed, "the « expands the dock again");
|
||||
assert!(expanded(h.column_left()), "the group is back: {}", h.column_left());
|
||||
assert_eq!(h.icons().len(), icon_panel_count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_collapsed_panel_pops_out_left_of_the_column() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
|
||||
h.settle();
|
||||
// Properties, Layers, Libraries head the column.
|
||||
let layers = h.icons()[1];
|
||||
h.click(layers.center());
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
|
||||
h.settle();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
let column = h.column_left();
|
||||
assert!(rect.right() <= column, "the flyout {rect:?} stays left of the column at {column}");
|
||||
assert!(rect.left() >= 0.0 && rect.bottom() <= SCREEN.y, "the flyout {rect:?} is on screen");
|
||||
assert!(rect.height() > 200.0, "Layers gets room for its list: {rect:?}");
|
||||
// Its » puts it away.
|
||||
h.click(egui::pos2(rect.right() - 13.0, rect.top() + 13.0));
|
||||
assert_eq!(h.app.ui.open_panel, None, "the flyout's » closes it");
|
||||
// Expanding with a panel popped out shows its tab.
|
||||
h.click(layers.center());
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"));
|
||||
h.click(h.arrow());
|
||||
assert!(!h.app.ui.dock_collapsed);
|
||||
assert_eq!((h.app.ui.open_panel.as_deref(), h.app.ui.dock_tab), (None, DockTab::Layers));
|
||||
// Properties pops out as wide as the dock and shows its sections, not a sliver, also as
|
||||
// the first flyout of a session (a fresh harness: the flyouts share one area, which
|
||||
// would otherwise keep the size the Layers flyout gave it).
|
||||
let mut h = Harness::new();
|
||||
h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
|
||||
h.app.run("window.panel", json!({"panel": "properties"})).unwrap();
|
||||
h.settle();
|
||||
let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap();
|
||||
assert!(rect.width() >= DOCK_WIDTH && rect.right() <= h.column_left(), "Properties flyout {rect:?}");
|
||||
// Taller than the 400 points egui sizes a new area at: its sections (Align, Quick
|
||||
// Actions) aren't cut off below Appearance.
|
||||
assert!(rect.height() > 450.0 && rect.bottom() <= SCREEN.y, "Properties flyout {rect:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_collapse_command_sets_or_toggles_the_dock() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(true));
|
||||
assert!(app.ui.dock_collapsed && crate::menus::checked(&app, "window.collapseDock", &json!({})) == Some(true));
|
||||
assert_eq!(app.run("window.collapseDock", json!({"collapsed": true})).unwrap(), json!(true));
|
||||
assert!(app.run("window.collapseDock", json!({"collapsed": "yes"})).is_err());
|
||||
// Window › Layers pops Layers out of its icon while collapsed.
|
||||
app.run("window.panel", json!({"panel": "layers"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
|
||||
assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true));
|
||||
assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(false));
|
||||
assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Layers));
|
||||
app.run("window.panel", json!({"panel": "properties"})).unwrap();
|
||||
assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Properties));
|
||||
// Saved with the preferences and with a user workspace; the built-in ones expand it.
|
||||
app.run("window.collapseDock", json!({"collapsed": true})).unwrap();
|
||||
let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
|
||||
assert!(saved.sanitized().dock_collapsed);
|
||||
app.run("window.workspace.new", json!({"name": "Icons"})).unwrap();
|
||||
app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
|
||||
assert!(!app.ui.dock_collapsed, "Essentials shows the panels");
|
||||
app.run("window.workspace", json!({"name": "Icons"})).unwrap();
|
||||
assert!(app.ui.dock_collapsed, "the user workspace keeps them collapsed");
|
||||
}
|
||||
}
|
||||
@@ -569,3 +569,6 @@
|
||||
Make Compound Path Vytvořit složenou cestu
|
||||
Release Compound Path Uvolnit složenou cestu
|
||||
Select All Vybrat vše
|
||||
Collapse Panels to Icons Sbalit panely na ikony
|
||||
Collapse to Icons Sbalit na ikony
|
||||
Expand Panels Rozbalit panely
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -569,3 +569,6 @@
|
||||
Make Compound Path 複合パスを作成
|
||||
Release Compound Path 複合パスを解除
|
||||
Select All すべてを選択
|
||||
Collapse Panels to Icons パネルをアイコンに折りたたむ
|
||||
Collapse to Icons アイコンに折りたたむ
|
||||
Expand Panels パネルを展開
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -148,6 +148,7 @@
|
||||
Appearance Panel 外觀面板
|
||||
Appearance panel 外觀面板
|
||||
Applies when the linked file is relinked or updated. 重新連結或更新連結檔案時套用。
|
||||
Applies the next time VectorCraft starts. 下次啟動 VectorCraft 時生效。
|
||||
Apply Brush 套用筆刷
|
||||
Apply Character Style 套用字元樣式
|
||||
Apply Effect 套用效果
|
||||
@@ -361,6 +362,8 @@
|
||||
CMYK CMYK
|
||||
CMYK Color CMYK 色彩
|
||||
CMYK colours print with their own values; off, they go through the colour settings too. CMYK 顏色以其本身的數值列印;關閉時,也會經過色彩設定處理。
|
||||
Collapse Panels to Icons 將面板收合為圖示
|
||||
Collapse to Icons 收合為圖示
|
||||
Collate 逐份列印
|
||||
Collect for Export 收集以供匯出
|
||||
Collect in New Layer 收集至新圖層
|
||||
@@ -815,6 +818,7 @@
|
||||
Expand Gradient Mesh 展開漸層網格
|
||||
Expand Gradient To 展開漸層為
|
||||
Expand Options 展開選項
|
||||
Expand Panels 展開面板
|
||||
Expand Repeat 展開重複
|
||||
Expand Shape 展開形狀
|
||||
Expand: 展開:
|
||||
@@ -1017,6 +1021,8 @@
|
||||
Graphic Styles 繪圖樣式
|
||||
Graphic Styles Options 繪圖樣式選項
|
||||
Graphics 圖形
|
||||
Graphics Processor 圖形處理器
|
||||
Graphics: {adapter} 圖形:{adapter}
|
||||
Gravity 重力
|
||||
Grays 灰色
|
||||
Grays and Neutrals 灰色與中性色
|
||||
@@ -1073,6 +1079,7 @@
|
||||
Hide Transparency Grid 隱藏透明度格點
|
||||
High 高
|
||||
High (300 ppi) 高 (300 ppi)
|
||||
High Performance (discrete) 高效能 (獨立顯示卡)
|
||||
Highlight Alternate Glyphs 醒目提示替代字符
|
||||
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
|
||||
Highlight Substituted Fonts 醒目提示被取代的字體
|
||||
@@ -1775,6 +1782,7 @@
|
||||
Polygon Properties: 多邊形內容:
|
||||
Polygon Tool 多邊形工具
|
||||
Portrait 直向
|
||||
Power Saving (integrated) 節能 (內建顯示晶片)
|
||||
Portuguese 葡萄牙文
|
||||
Position 位置
|
||||
Postcard 明信片
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -293,6 +293,9 @@ pub struct VectorcraftApp {
|
||||
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
|
||||
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
|
||||
pub custom_titlebar: bool,
|
||||
/// The graphics adapter the window renders with ("name (backend)"), as the host reports it:
|
||||
/// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown.
|
||||
pub graphics_adapter: Option<String>,
|
||||
/// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details.
|
||||
pub place: place::PlaceState,
|
||||
/// The Paste commands can paste from the system clipboard alone: it holds something to paste
|
||||
@@ -367,6 +370,7 @@ impl VectorcraftApp {
|
||||
canvas_rect: None,
|
||||
hover_doc: None,
|
||||
custom_titlebar: false,
|
||||
graphics_adapter: None,
|
||||
place: Default::default(),
|
||||
system_paste: false,
|
||||
system_paste_at: f64::NEG_INFINITY,
|
||||
|
||||
+32
-19
@@ -172,6 +172,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("window.taskBar", "Contextual Task Bar", "", "{}"),
|
||||
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
|
||||
(
|
||||
"window.collapseDock",
|
||||
"Collapse Panels to Icons",
|
||||
"",
|
||||
"{collapsed?: bool} collapse the dock's Properties | Layers | Libraries group to icons (true), expand it (false) or toggle (omitted), as the double arrow at the top of the dock does; returns the new state",
|
||||
),
|
||||
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
|
||||
("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"),
|
||||
("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"),
|
||||
@@ -895,27 +901,35 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
app.ui.control_bar = on;
|
||||
Ok(json!(on))
|
||||
}
|
||||
"window.collapseDock" => {
|
||||
let collapsed = match p.get("collapsed") {
|
||||
None | Some(Value::Null) => !app.ui.dock_collapsed,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Some(Err("collapsed must be true or false".into())),
|
||||
};
|
||||
crate::dock::set_collapsed(app, collapsed);
|
||||
Ok(json!(collapsed))
|
||||
}
|
||||
"window.panel" => {
|
||||
let panel = s("panel").unwrap_or_default();
|
||||
match panel.as_str() {
|
||||
"properties" => {
|
||||
app.ui.dock_tab = DockTab::Properties;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"layers" => {
|
||||
app.ui.dock_tab = DockTab::Layers;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"libraries" => {
|
||||
app.ui.dock_tab = DockTab::Libraries;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
p if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
|
||||
match (panel.as_str(), DockTab::from_id(&panel)) {
|
||||
// A collapsed dock pops the panel out of its icon, like the icon panels.
|
||||
(p, Some(tab)) if app.ui.dock_collapsed => {
|
||||
app.ui.dock_tab = tab;
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
|
||||
app.ui.dock = true;
|
||||
Ok(json!({"open": app.ui.open_panel}))
|
||||
}
|
||||
other => Err(format!("unknown panel `{other}`")),
|
||||
(_, Some(tab)) => {
|
||||
app.ui.dock_tab = tab;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
(p, None) if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
|
||||
app.ui.dock = true;
|
||||
Ok(json!({"open": app.ui.open_panel}))
|
||||
}
|
||||
(other, None) => Err(format!("unknown panel `{other}`")),
|
||||
}
|
||||
}
|
||||
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
|
||||
@@ -1195,13 +1209,12 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
"window.taskBar" => app.ui.task_bar,
|
||||
"window.panel" => {
|
||||
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
|
||||
match panel {
|
||||
"properties" => app.ui.dock_tab == DockTab::Properties,
|
||||
"layers" => app.ui.dock_tab == DockTab::Layers,
|
||||
"libraries" => app.ui.dock_tab == DockTab::Libraries,
|
||||
match DockTab::from_id(panel) {
|
||||
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
|
||||
_ => app.ui.open_panel.as_deref() == Some(panel),
|
||||
}
|
||||
}
|
||||
"window.collapseDock" => app.ui.dock_collapsed,
|
||||
"window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()),
|
||||
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
|
||||
"view.slices.lock" => app.session.slices_locked(),
|
||||
|
||||
@@ -279,6 +279,10 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
||||
d.fields.insert(sp.key.into(), json!(opts[i].0));
|
||||
}
|
||||
});
|
||||
if sp.key == "gpuPreference" {
|
||||
// The window's graphics device is chosen once, when the app starts.
|
||||
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
PrefKind::Color => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
@@ -393,6 +397,38 @@ mod tests {
|
||||
assert_eq!(a.session.prefs.ui_brightness, "light");
|
||||
}
|
||||
|
||||
/// Performance › Graphics Processor (#306): shown with its restart note, applied by OK and
|
||||
/// saved with the UI state under the key the desktop app reads before the window opens.
|
||||
#[test]
|
||||
fn graphics_processor_preference_shows_and_persists() {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut a = app();
|
||||
open(&mut a, Some("Performance"));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// The window sizes itself on the first frame and draws on the second.
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
assert!(shown.iter().any(|t| t.starts_with("Graphics Processor")), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Power Saving (integrated)"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
|
||||
a.ui.dialog.as_mut().unwrap().fields.insert("gpuPreference".into(), json!("highPerformance"));
|
||||
confirm(&mut a).unwrap();
|
||||
assert_eq!(a.session.prefs.gpu_preference, "highPerformance");
|
||||
let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap();
|
||||
assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_renders_fields() {
|
||||
for c in PREF_CATEGORIES {
|
||||
|
||||
@@ -76,6 +76,24 @@ pub enum DockTab {
|
||||
Libraries,
|
||||
}
|
||||
|
||||
impl DockTab {
|
||||
/// The tab's panel id (`window.panel`), English label and icon (when the dock is collapsed).
|
||||
pub fn info(self) -> (&'static str, &'static str, &'static str) {
|
||||
match self {
|
||||
DockTab::Properties => ("properties", "Properties", "dc-options"),
|
||||
DockTab::Layers => ("layers", "Layers", "layers"),
|
||||
DockTab::Libraries => ("libraries", "Libraries", "library"),
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [DockTab; 3] = [DockTab::Properties, DockTab::Layers, DockTab::Libraries];
|
||||
|
||||
/// The tab whose panel id is `id`.
|
||||
pub fn from_id(id: &str) -> Option<DockTab> {
|
||||
DockTab::ALL.into_iter().find(|t| t.info().0 == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panels that live as collapsed icons in the dock (Essentials Classic).
|
||||
pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
("color", "Color", "palette"),
|
||||
@@ -219,7 +237,12 @@ pub struct UiState {
|
||||
pub legacy_language: Option<String>,
|
||||
pub brightness: Brightness,
|
||||
pub dock_tab: DockTab,
|
||||
/// Icon panel currently popped out of the collapsed column.
|
||||
/// The dock's tabbed group (Properties | Layers | Libraries) is collapsed to icons at the top of
|
||||
/// the icon column (the dock's double arrow, `window.collapseDock`).
|
||||
#[serde(default)]
|
||||
pub dock_collapsed: bool,
|
||||
/// Icon panel currently popped out of the collapsed column (also `properties`, `layers` or
|
||||
/// `libraries` while the dock is collapsed).
|
||||
pub open_panel: Option<String>,
|
||||
pub control_bar: bool,
|
||||
pub toolbar: bool,
|
||||
@@ -358,6 +381,7 @@ impl Default for UiState {
|
||||
legacy_language: None,
|
||||
brightness: Brightness::MediumDark,
|
||||
dock_tab: DockTab::Properties,
|
||||
dock_collapsed: false,
|
||||
open_panel: None,
|
||||
control_bar: false,
|
||||
toolbar: true,
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct Workspace {
|
||||
pub task_bar: bool,
|
||||
pub dock: bool,
|
||||
pub dock_tab: DockTab,
|
||||
/// The dock's tabbed group is collapsed to icons (built-in workspaces: expanded).
|
||||
pub dock_collapsed: bool,
|
||||
pub open_panel: Option<String>,
|
||||
pub status_bar: bool,
|
||||
}
|
||||
@@ -115,6 +117,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
|
||||
task_bar: ui.task_bar,
|
||||
dock: ui.dock,
|
||||
dock_tab: ui.dock_tab,
|
||||
dock_collapsed: ui.dock_collapsed,
|
||||
open_panel: ui.open_panel.clone(),
|
||||
status_bar: ui.status_bar,
|
||||
}
|
||||
@@ -130,6 +133,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
|
||||
ui.task_bar = w.task_bar;
|
||||
ui.dock = w.dock;
|
||||
ui.dock_tab = w.dock_tab;
|
||||
ui.dock_collapsed = w.dock_collapsed;
|
||||
ui.open_panel = w.open_panel.clone();
|
||||
ui.status_bar = w.status_bar;
|
||||
ui.flyout = None;
|
||||
|
||||
@@ -180,6 +180,13 @@ a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Col
|
||||
Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run
|
||||
`window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items.
|
||||
|
||||
Collapsing the dock: `window.collapseDock {collapsed?}` (the » at the top of the dock; omitted toggles) hides the
|
||||
Properties | Layers | Libraries group and puts its three panels as icons at the top of the icon column, under a «
|
||||
that expands them again. While collapsed, those icons and `window.panel {panel: "properties"|"layers"|"libraries"}`
|
||||
pop the panel out next to the column like the other icon panels (`ui.dock_collapsed`, `ui.open_panel` in
|
||||
`ui.inspect`); expanding with one popped out shows its tab. The state is saved with the preferences and in user
|
||||
workspaces; the built-in workspaces expand the dock.
|
||||
|
||||
Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection
|
||||
(fields `preset`: a preset name, setting it loads that preset's options; the option keys of
|
||||
`object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,
|
||||
|
||||
@@ -24,6 +24,14 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
|
||||
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
|
||||
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
|
||||
|
||||
## Desktop graphics processor
|
||||
|
||||
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. The desktop app (`apps/vectorcraft/src/main.rs`) therefore asks wgpu for the **power-saving** adapter by default, which is the integrated GPU on hybrid-graphics laptops. Presenting frames rendered on a discrete GPU through the integrated one made the window flicker on some laptops (#306). A machine with one GPU gets that GPU either way.
|
||||
|
||||
- **Preferences › Performance › Graphics Processor** (`gpuPreference`: `powerSaving` or `highPerformance`) picks the other one. The adapter is chosen when the window opens, so a change applies after a restart.
|
||||
- The `WGPU_POWER_PREF` environment variable (`low`, `high` or `none`) overrides the preference.
|
||||
- Help › About and the control channel's `ui.inspect` (`graphicsAdapter`) show the adapter in use, and the app logs it at startup.
|
||||
|
||||
## Fonts: craft-fonts (optional build input)
|
||||
|
||||
Font files are never committed to this repository. Fonts shared by the Crafting Apps live in
|
||||
|
||||
Reference in new issue
Block a user