Merge pull request #6709 from dotCooCoo/feat/hang-report

Help: a stalled interface writes a hang report, and Copy Troubleshooting Info copies it
This commit is contained in:
BFlat
2026-11-12 13:18:28 +07:00
committed by GitHub
44 changed files with 1734 additions and 4 deletions
+41 -1
View File
@@ -9,6 +9,7 @@ use super::{App, VectorcraftApp, file_access, window};
#[cfg(target_os = "macos")]
use super::{mac_menu, mac_window, open_documents};
use vectorcraft_ui_egui::dialogs::safe_mode;
use vectorcraft_ui_egui::hang_beacon::{self, BEACON, FrameTime, Stage};
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
@@ -34,12 +35,16 @@ impl eframe::App for App {
// control channel asks for, and the frames carrying input it injected, to the automation roots
// (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let _timed = BEACON.enter_timed(Stage::Logic);
file_access::unconfined(|| self.logic_frame(ctx, frame));
self.app.finish_ime_logic(ctx);
BEACON.enter(Stage::Idle);
}
/// Restore the borderless caption, apply platform menu and pen input, and hand the frame to the
/// app's raw-input hook while the keyboard user is unconfined.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let _timed = BEACON.enter_timed(Stage::Input);
self.frame_start = Some(std::time::Instant::now());
// winit gives the window its caption back when it changes the window's flags (maximize,
// restore, …): take it off again before the frame is laid out (#1758).
if let Some(window_frame) = self.window_frame.as_mut() {
@@ -60,9 +65,10 @@ impl eframe::App for App {
// that no mouse button is down.
#[cfg(target_os = "macos")]
self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0);
BEACON.enter(Stage::Idle);
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let _timed = BEACON.enter_timed(Stage::Interface);
#[cfg(target_os = "macos")]
{
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
@@ -89,8 +95,13 @@ impl eframe::App for App {
if !self.app.file_dialog_open() && self.app.take_ime_discard() {
discard_marked_text();
}
self.end_frame(ui.ctx(), frame);
}
fn on_exit(&mut self) {
// The window is closing: saving and teardown are not timed for the hang report, and a report
// of a stall the interface came back from is removed.
let log_dir = super::prefs::log_dir().filter(|_| super::prefs::prefs_enabled());
super::hang_watch::closing(log_dir.as_deref(), &super::hang_watch::CLOSING, &BEACON);
file_access::unconfined(|| save_prefs(&self.app));
// A ready update installs once the process has exited. The macOS exit below skips
// dropping the app, so the helper is started here.
@@ -102,6 +113,35 @@ impl eframe::App for App {
}
impl App {
/// The frame's times for the hang report, and its last stage ([`hang_beacon::after_frame`]): a
/// focused, shown window whose windows had input waits for the renderer and the next frame,
/// which the input asks for at once; any other frame leaves the interface thread idle
/// (`hang_watch`).
fn end_frame(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
// A pass egui discards and runs again is not the frame's end: the pass after it is.
if ctx.will_discard() {
return;
}
if let Some(start) = self.frame_start.take() {
let work_ms = start.elapsed().as_secs_f32() * 1000.0;
let previous_total_ms = frame.info().cpu_usage.map(|s| s * 1000.0);
BEACON.frame(FrameTime { work_ms, previous_total_ms }, self.app.session.documents().len());
}
// Hide on macOS hides the window at once, before the window reports losing focus.
let main_shown = ctx.input(|i| i.focused && i.viewport().minimized != Some(true))
&& self.winit_window.as_ref().is_none_or(|w| w.is_visible() != Some(false));
// A floating panel or document window with the focus counts as well, with the commands it
// was sent after its pass, and those its pass sent (`hang_beacon::watch_input`).
let shown = ctx.input(|i| hang_beacon::shown_window(main_shown, &i.raw.viewports));
let handed_off = BEACON.take_handed_off();
let input = BEACON.take_input();
let stage = match shown {
Some(id) => ctx.viewport_for(id, |vp| hang_beacon::after_frame(input && !handed_off.contains(&id), true, &vp.commands)),
None => hang_beacon::after_frame(input, false, &[]),
};
BEACON.enter(stage);
}
fn logic_frame(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
if self.winit_window.as_ref().is_some_and(|w| !w.has_focus() || w.is_visible() == Some(false) || w.is_minimized() == Some(true)) {
+445
View File
@@ -0,0 +1,445 @@
//! The hang report. A thread reads the interface thread's marks
//! ([`vectorcraft_ui_egui::hang_beacon`]) every [`POLL`]. When the interface thread has made no
//! mark for [`STALL`] while inside a stage of its frame, the thread writes what the app knows to the
//! log (`logs/vectorcraft.log`) and to `logs/hang-report-<process id>.txt`: the stage, a clipboard
//! call in progress, the last commands, the last frames' times, the platform, the window system, the
//! graphics processor and the present mode the app asked for, and how long the session has run with
//! how many documents open. The process holds a lock on the file until the interface thread answers
//! again; then the log says after how long and the file is removed. A file nobody holds at a later
//! launch was left by a session that was closed while it did not answer, and a locked one belongs to
//! another instance that is stalled now and is left alone. That launch writes the report to the log,
//! moves the newest abandoned file to `hang-report.1.txt` and removes the others, says so in the
//! status bar, and Help › Copy Troubleshooting Info copies the reports. Where the file system has no
//! file locks, the file is written and taken without one.
//!
//! No native stack is recorded: the marks are what the report has. The report holds command ids and
//! counts, never document contents or file paths. [`STALL`] is VectorCraft's own value.
//!
//! A stall in the renderer is timed only after a frame that had input in any window: input asks for
//! the next frame at once, so a frame that does not start then is late. After a frame without
//! input, the window waiting for input looks the same as one stuck drawing, so that time is not
//! timed. Nor is the time after a frame that minimizes or hides the main window or hands it to the
//! system to move or resize. A window the system stops redrawing while it is hidden can still look
//! like a stall after a frame with input; the report's stage says so. A system file dialog shown in
//! line (on Windows and Linux) and an open menu of the macOS menu bar wait for the user and are not
//! timed.
use std::fs::File;
use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant, SystemTime};
#[cfg(target_os = "macos")]
use vectorcraft_ui_egui::hang_beacon::Activity;
use vectorcraft_ui_egui::hang_beacon::{BEACON, Beacon, FrameTime, Sample, Stage};
use super::logging::timestamp;
/// How long the interface thread may stay in one stage before the report is written.
pub const STALL: Duration = Duration::from_secs(3);
/// How often the watching thread reads the marks.
pub const POLL: Duration = Duration::from_millis(500);
/// The report of a stall that has not ended, in the log folder: this, the process id, `.txt`.
pub const REPORT_PREFIX: &str = "hang-report-";
/// At most this many report files are taken when a launch looks for abandoned ones; older abandoned
/// ones are removed.
const MAX_REPORT_FILES: usize = 16;
/// At most this many entries of the log folder are looked at for report files.
const MAX_SCANNED_ENTRIES: usize = 1024;
/// The report the last session left, moved here at launch.
pub const PREVIOUS_FILE: &str = "hang-report.1.txt";
/// A report file larger than this is not read.
pub const MAX_REPORT_BYTES: u64 = 64 * 1024;
/// Environment values in the report are cut to this many characters.
const MAX_VALUE_CHARS: usize = 64;
/// What [`Watch::check`] found.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Event {
/// The interface thread has been in the sample's stage, without a mark, for this long.
Stalled(Duration),
/// The interface thread made a mark again after a [`Event::Stalled`], about this long after
/// the stall began.
Answered(Duration),
}
/// Decides from successive samples when the interface thread has stalled and when it answers
/// again. Time comes from the caller, so tests drive it without waiting.
#[derive(Debug)]
pub struct Watch {
stall: Duration,
/// The step seen in a busy stage and when it was first seen.
seen: Option<(u64, Duration)>,
reported: bool,
}
impl Watch {
pub fn new(stall: Duration) -> Watch {
Watch { stall, seen: None, reported: false }
}
/// The marks read at `now` (any monotonic clock that started before the first call).
pub fn check(&mut self, now: Duration, sample: Sample) -> Option<Event> {
let same = self.seen.is_some_and(|(step, _)| step == sample.step);
if same && sample.busy() {
let since = self.seen.map_or(now, |(_, since)| since);
let stalled = now.saturating_sub(since);
if !self.reported && stalled >= self.stall {
self.reported = true;
return Some(Event::Stalled(stalled));
}
return None;
}
let answered = match (self.reported, self.seen) {
(true, Some((_, since))) => Some(Event::Answered(now.saturating_sub(since))),
_ => None,
};
self.reported = false;
self.seen = sample.busy().then_some((sample.step, now));
answered
}
}
/// What the report says about the platform.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Facts {
/// The window system the window opened on: Wayland, X11, AppKit, Win32, other or unknown.
pub window_system: String,
/// `XDG_CURRENT_DESKTOP`.
pub desktop: Option<String>,
/// `XDG_SESSION_TYPE`.
pub session_type: Option<String>,
/// Running in a Flatpak sandbox.
pub flatpak: bool,
/// The graphics processor the window draws with.
pub graphics: Option<String>,
/// The present mode and frame queue the app asked the window surface for.
pub surface: String,
}
impl Facts {
/// The facts from the environment (`env`, `flatpak_info`: whether `/.flatpak-info` exists) and
/// what the app knows.
pub fn new(env: impl Fn(&str) -> Option<String>, flatpak_info: bool, window_system: &str, graphics: Option<String>, surface: String) -> Facts {
let value = |name: &str| env(name).map(|v| clean(&v)).filter(|v| !v.is_empty());
Facts {
window_system: window_system.to_string(),
desktop: value("XDG_CURRENT_DESKTOP"),
session_type: value("XDG_SESSION_TYPE"),
flatpak: flatpak_info || env("FLATPAK_ID").is_some_and(|v| !v.is_empty()),
graphics,
surface,
}
}
/// The facts as report lines.
pub fn lines(&self) -> String {
let or_unknown = |v: &Option<String>| v.clone().unwrap_or_else(|| "unknown".to_string());
format!(
"VectorCraft {} on {} {}\nWindow system: {}; desktop: {}; session type: {}; Flatpak: {}\nGraphics: {}; {}\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
self.window_system,
or_unknown(&self.desktop),
or_unknown(&self.session_type),
if self.flatpak { "yes" } else { "no" },
or_unknown(&self.graphics),
self.surface,
)
}
}
/// `v` with control characters removed, cut to [`MAX_VALUE_CHARS`] characters.
fn clean(v: &str) -> String {
v.chars().filter(|c| !c.is_control()).take(MAX_VALUE_CHARS).collect::<String>().trim().to_string()
}
/// The marks other than the stage: the last commands, the last frames and the session.
fn activity_lines(beacon: &Beacon, uptime: Duration) -> String {
let commands = match beacon.recent_commands() {
Some(c) if c.is_empty() => "none".to_string(),
Some(c) => c.join(", "),
None => "unavailable (being written)".to_string(),
};
let (work, total) = match beacon.recent_frames() {
Some(frames) => {
let list = |f: &dyn Fn(&FrameTime) -> Option<f32>| {
let times: Vec<String> = frames.iter().filter_map(f).map(|ms| format!("{ms:.1}")).collect();
if times.is_empty() { "none".to_string() } else { times.join(", ") }
};
(list(&|t| Some(t.work_ms)), list(&|t| t.previous_total_ms))
}
None => ("unavailable (being written)".to_string(), "unavailable (being written)".to_string()),
};
format!(
"Recent commands, oldest first: {commands}\nApp work per frame (ms), oldest first: {work}\nWork and drawing per frame, without waiting for the display (ms), oldest first: {total}\nSession: running for {} s, {} documents open\n",
uptime.as_secs(),
beacon.documents(),
)
}
/// The report of a stall that has lasted `stalled` in `sample`'s stage, written at `at`.
pub fn report(at: SystemTime, stalled: Duration, sample: Sample, beacon: &Beacon, uptime: Duration, facts: &Facts) -> String {
let waiting = sample.activity.describe().map(|a| format!("Waiting on: {a}\n")).unwrap_or_default();
format!(
"VectorCraft hang report, {}\nThe interface thread has not moved on for {:.1} s.\nStage: {}\n{waiting}{}{}No native stack is recorded: the stage and the activity are what the app marks.\n",
timestamp(at),
stalled.as_secs_f64(),
sample.stage.describe(),
activity_lines(beacon, uptime),
facts.lines(),
)
}
/// What the watching thread and Help › Copy Troubleshooting Info share.
pub struct Shared {
pub facts: Facts,
pub started: Instant,
/// This session's last report.
pub last: Mutex<Option<String>>,
/// The report the last session left ([`take_previous`]).
pub previous: Option<String>,
}
/// The text Help › Copy Troubleshooting Info copies.
pub fn troubleshooting(shared: &Shared, beacon: &Beacon, at: SystemTime) -> String {
let last = shared.last.lock().unwrap_or_else(PoisonError::into_inner).clone();
format!(
"VectorCraft troubleshooting info, {}\n{}{}\nThis session's last hang report:\n{}\nThe hang report the last session left (it was closed while it did not answer):\n{}\n",
timestamp(at),
shared.facts.lines(),
activity_lines(beacon, shared.started.elapsed()),
last.as_deref().unwrap_or("none\n"),
shared.previous.as_deref().unwrap_or("none\n"),
)
}
/// The report file of this process in `dir`.
fn report_path(dir: &Path) -> PathBuf {
dir.join(format!("{REPORT_PREFIX}{}.txt", std::process::id()))
}
/// A report file this process wrote and holds locked until the interface answers again.
#[derive(Debug)]
pub struct Held {
path: PathBuf,
_file: File,
}
/// Write `text` to this process's report file in `dir` and lock it. Where the file system has no
/// file locks, the file is written unlocked and the log says so.
fn write_report(dir: &Path, text: &str) -> std::io::Result<Held> {
std::fs::create_dir_all(dir)?;
let path = report_path(dir);
let mut file = File::create(&path)?;
if let Err(e) = file.lock() {
log::warn!("the hang report file is written without a lock: {e}");
}
file.write_all(text.as_bytes())?;
Ok(Held { path, _file: file })
}
/// Whether a report file whose `try_lock` gave `locked` was abandoned: nobody holds it. A file
/// system without file locks answers [`std::io::ErrorKind::Unsupported`], and its files count as
/// abandoned; any other error leaves the file alone.
fn abandoned(locked: Result<(), std::fs::TryLockError>) -> bool {
match locked {
Ok(()) => true,
Err(std::fs::TryLockError::Error(e)) => e.kind() == std::io::ErrorKind::Unsupported,
Err(std::fs::TryLockError::WouldBlock) => false,
}
}
/// The report file at `path`, opened and locked, with its length, when it was abandoned
/// ([`abandoned`]) and is not empty (an empty one may be being written).
fn open_abandoned(path: &Path) -> Option<(File, u64)> {
let file = File::options().read(true).write(true).open(path).ok()?;
if !abandoned(file.try_lock()) {
return None;
}
let len = file.metadata().map_or(0, |m| m.len());
(len > 0).then_some((file, len))
}
/// The reports that sessions closed while they did not answer left in `dir`, oldest first; `None`
/// when there are none. The newest file is moved to [`PREVIOUS_FILE`] and the others are removed
/// once read. Of more than [`MAX_REPORT_FILES`], the newest are taken and the older ones removed
/// unread; the first [`MAX_SCANNED_ENTRIES`] entries of `dir` are looked at. A report file another
/// process holds locked, or one still empty, is left alone. A file over [`MAX_REPORT_BYTES`] is
/// taken without being read. A file that can't be moved is removed, so no later launch offers it
/// again: the caller writes the reports to the log. Each file is opened once.
pub fn take_previous(dir: &Path) -> Option<String> {
let mut found: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(dir)
.ok()?
.take(MAX_SCANNED_ENTRIES)
.filter_map(Result::ok)
.filter(|e| {
e.file_name()
.to_str()
.and_then(|n| n.strip_prefix(REPORT_PREFIX)?.strip_suffix(".txt"))
.is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
})
.map(|e| (e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH), e.path()))
.collect();
found.sort();
// Newest first: only abandoned reports count toward the limit.
let mut texts = Vec::new();
for (_, path) in found.into_iter().rev() {
let Some((file, len)) = open_abandoned(&path) else { continue };
if texts.len() == MAX_REPORT_FILES {
drop(file);
remove_quietly(&path);
continue;
}
let mut bytes = Vec::new();
let text = if len <= MAX_REPORT_BYTES && (&file).take(MAX_REPORT_BYTES).read_to_end(&mut bytes).is_ok() {
String::from_utf8_lossy(&bytes).into_owned()
} else {
format!("The report was larger than {MAX_REPORT_BYTES} bytes and was not read.\n")
};
// Closed (and unlocked) before it is moved: Windows moves no open file.
drop(file);
if !texts.is_empty() {
remove_quietly(&path);
} else if let Err(e) = std::fs::rename(&path, dir.join(PREVIOUS_FILE)) {
log::warn!("couldn't move the hang report file to {PREVIOUS_FILE}: {e}");
remove_quietly(&path);
}
texts.push(text);
}
texts.reverse();
(!texts.is_empty()).then(|| texts.join("\n"))
}
/// Remove the report file at `path`; a failure goes to the log.
fn remove_quietly(path: &Path) {
if let Err(e) = std::fs::remove_file(path) {
log::warn!("couldn't remove the hang report file: {e}");
}
}
/// Set when the app closes normally ([`closing`]): the watching thread writes no report file after it.
pub static CLOSING: AtomicBool = AtomicBool::new(false);
/// Remove `held`'s file, closing it first. → the error message; a file already gone is none.
fn remove(held: Held) -> Option<String> {
let Held { path, _file: file } = held;
drop(file);
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Some(e.to_string()),
_ => None,
}
}
/// Act on what [`Watch::check`] found: write (and hold) or remove this process's report file in
/// `dir`, then log it. The file comes first: logging can wait on what the interface thread waits on.
#[allow(clippy::too_many_arguments)]
pub fn record(
event: Event,
sample: Sample,
beacon: &Beacon,
shared: &Shared,
dir: Option<&Path>,
held: &mut Option<Held>,
closing: &AtomicBool,
at: SystemTime,
) {
match event {
Event::Stalled(stalled) => {
let text = report(at, stalled, sample, beacon, shared.started.elapsed(), &shared.facts);
let mut failed = None;
if let Some(dir) = dir.filter(|_| !closing.load(Ordering::Acquire)) {
match write_report(dir, &text) {
Ok(file) => *held = Some(file),
Err(e) => failed = Some(e.to_string()),
}
// The app began to close while the file was written: it is not a report to leave.
if closing.load(Ordering::Acquire) {
failed = held.take().and_then(remove).or(failed);
}
}
*shared.last.lock().unwrap_or_else(PoisonError::into_inner) = Some(text.clone());
log::warn!("the interface stopped answering:\n{}", text.trim_end());
if let Some(e) = failed {
log::warn!("couldn't write or remove the hang report file: {e}");
}
}
Event::Answered(after) => {
let failed = held.take().and_then(remove);
log::warn!("the interface answered again about {:.1} s after it stopped", after.as_secs_f64());
if let Some(e) = failed {
log::warn!("couldn't remove the hang report file: {e}");
}
}
}
}
/// The app is closing normally: mark the interface thread idle on `beacon` (saving and teardown are
/// not timed), remove this process's report file in `dir` and have the watching thread write no
/// more. It does not wait for the watching thread. The file can be removed while that thread holds
/// it open and locked: the standard library opens files with `FILE_SHARE_DELETE` on Windows, and a
/// byte-range lock does not prevent deletion. Where Windows deletes a file only once its last
/// handle closes, the name goes when the process ends.
pub fn closing(dir: Option<&Path>, closing: &AtomicBool, beacon: &Beacon) {
beacon.enter(Stage::Idle);
closing.store(true, Ordering::Release);
if let Some(dir) = dir {
match std::fs::remove_file(report_path(dir)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("couldn't remove the hang report file: {e}"),
_ => {}
}
}
}
/// The window system `window` opened on, for [`Facts::window_system`].
pub fn window_system(window: Option<&winit::window::Window>) -> &'static str {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
match window.and_then(|w| w.window_handle().ok()).map(|h| h.as_raw()) {
Some(RawWindowHandle::Wayland(_)) => "Wayland",
Some(RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) => "X11",
Some(RawWindowHandle::AppKit(_)) => "AppKit",
Some(RawWindowHandle::Win32(_)) => "Win32",
Some(_) => "other",
None => "unknown",
}
}
/// The present mode and frame queue the app asked the window surface for, for [`Facts::surface`];
/// `None` when the window has no wgpu renderer.
pub fn surface(config: Option<(String, Option<u32>)>) -> String {
match config {
Some((mode, Some(frames))) => format!("requested present mode {mode}, frame latency {frames}"),
Some((mode, None)) => format!("requested present mode {mode}"),
None => "no wgpu renderer".to_string(),
}
}
/// Start the watching thread; the report file goes in `dir` (none: the log only).
pub fn start(dir: Option<PathBuf>, shared: Arc<Shared>) {
let spawned = std::thread::Builder::new().name("hang-watch".into()).spawn(move || {
let mut watch = Watch::new(STALL);
let mut held = None;
loop {
std::thread::sleep(POLL);
#[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
let mut sample = BEACON.sample();
// An open menu of the menu bar waits for the user.
#[cfg(target_os = "macos")]
if super::mac_menu::is_tracking() {
sample.activity = Activity::Menu;
}
if let Some(event) = watch.check(shared.started.elapsed(), sample) {
record(event, sample, &BEACON, &shared, dir.as_deref(), &mut held, &CLOSING, SystemTime::now());
}
}
});
if let Err(e) = spawned {
log::warn!("no hang report this session: the watching thread didn't start: {e}");
}
}
#[cfg(test)]
mod tests;
+270
View File
@@ -0,0 +1,270 @@
use super::*;
use std::time::UNIX_EPOCH;
use vectorcraft_ui_egui::hang_beacon::{Activity, Stage};
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
fn sample(step: u64, stage: Stage) -> Sample {
Sample { step, stage, activity: Activity::None }
}
fn facts() -> Facts {
let env = |name: &str| match name {
"XDG_CURRENT_DESKTOP" => Some("KDE".to_string()),
"XDG_SESSION_TYPE" => Some("wayland\u{7}".to_string()),
_ => None,
};
Facts::new(env, true, "Wayland", Some("Test GPU (Vulkan, DiscreteGpu)".to_string()), surface(Some(("AutoVsync".to_string(), Some(1)))))
}
fn shared(previous: Option<String>) -> Shared {
Shared { facts: facts(), started: Instant::now(), last: Mutex::new(None), previous }
}
#[test]
fn a_stage_held_for_the_stall_time_is_reported_once_and_its_end_after_it() {
let mut w = Watch::new(STALL);
assert_eq!(w.check(ms(0), sample(5, Stage::Interface)), None);
assert_eq!(w.check(ms(2_999), sample(5, Stage::Interface)), None);
assert_eq!(w.check(ms(3_000), sample(5, Stage::Interface)), Some(Event::Stalled(ms(3_000))));
assert_eq!(w.check(ms(9_000), sample(5, Stage::Interface)), None);
assert_eq!(w.check(ms(9_500), sample(6, Stage::Idle)), Some(Event::Answered(ms(9_500))));
assert_eq!(w.check(ms(20_000), sample(6, Stage::Idle)), None);
}
#[test]
fn frames_that_keep_moving_and_an_idle_window_are_never_reported() {
let mut w = Watch::new(STALL);
// A long animation: the step changes at every read.
for n in 0..100 {
assert_eq!(w.check(ms(n * 500), sample(n, Stage::Drawing)), None);
}
// A window waiting for input for a minute.
for n in 0..120 {
assert_eq!(w.check(ms(50_000 + n * 500), sample(1_000, Stage::Idle)), None);
}
}
#[test]
fn a_file_dialog_shown_in_line_is_waiting_for_the_user_not_stalled() {
let mut w = Watch::new(STALL);
let dialog = Sample { step: 7, stage: Stage::Interface, activity: Activity::Dialog };
for n in 0..60 {
assert_eq!(w.check(ms(n * 500), dialog), None);
}
// Back from the dialog, the command goes on and is timed again.
assert_eq!(w.check(ms(30_000), sample(8, Stage::Interface)), None);
assert_eq!(w.check(ms(33_000), sample(8, Stage::Interface)), Some(Event::Stalled(ms(3_000))));
}
#[test]
fn an_open_menu_bar_menu_is_waiting_for_the_user_not_stalled() {
let mut w = Watch::new(STALL);
let menu = Sample { step: 3, stage: Stage::Drawing, activity: Activity::Menu };
for n in 0..60 {
assert_eq!(w.check(ms(n * 500), menu), None);
}
}
#[test]
fn a_new_busy_step_starts_the_time_again() {
let mut w = Watch::new(STALL);
assert_eq!(w.check(ms(0), sample(1, Stage::Logic)), None);
assert_eq!(w.check(ms(2_500), sample(2, Stage::Interface)), None);
assert_eq!(w.check(ms(5_000), sample(2, Stage::Interface)), None);
assert_eq!(w.check(ms(5_500), sample(2, Stage::Interface)), Some(Event::Stalled(ms(3_000))));
// A mark inside the stall (a clipboard read ending) is an answer.
assert_eq!(w.check(ms(6_000), sample(3, Stage::Interface)), Some(Event::Answered(ms(3_500))));
}
#[test]
fn the_report_names_the_stage_the_activity_the_commands_and_the_platform() {
let beacon = Beacon::new();
beacon.command("edit.copy");
beacon.command("edit.paste");
beacon.frame(FrameTime { work_ms: 1.24, previous_total_ms: Some(3.5) }, 2);
let s = Sample { step: 9, stage: Stage::Interface, activity: Activity::ClipboardRead };
let text = report(UNIX_EPOCH + Duration::from_secs(1_794_385_145), ms(3_240), s, &beacon, Duration::from_secs(754), &facts());
for want in [
"VectorCraft hang report, 2026-11-11T08:19:05.000Z",
"has not moved on for 3.2 s",
"Stage: running the interface",
"Waiting on: reading the system clipboard",
"Recent commands, oldest first: edit.copy, edit.paste",
"App work per frame (ms), oldest first: 1.2",
"without waiting for the display (ms), oldest first: 3.5",
"Session: running for 754 s, 2 documents open",
"Window system: Wayland; desktop: KDE; session type: wayland; Flatpak: yes",
"Graphics: Test GPU (Vulkan, DiscreteGpu); requested present mode AutoVsync, frame latency 1",
] {
assert!(text.contains(want), "{want:?} missing from:\n{text}");
}
}
#[test]
fn environment_values_lose_control_characters_and_are_cut_short() {
let long = "x".repeat(500);
let env = |name: &str| (name == "XDG_CURRENT_DESKTOP").then(|| format!("A\nB{long}"));
let f = Facts::new(env, false, "X11", None, String::new());
assert_eq!(f.desktop.as_deref().map(str::len), Some(MAX_VALUE_CHARS));
assert!(f.desktop.as_deref().is_some_and(|d| d.starts_with("ABx")));
assert!(!f.flatpak);
assert!(f.lines().contains("Graphics: unknown"));
}
#[test]
fn a_stall_writes_the_report_file_and_its_end_removes_it() {
let dir = tempfile::tempdir().unwrap();
let shared = shared(None);
let beacon = Beacon::new();
let s = sample(4, Stage::Drawing);
let (mut held, open) = (None, AtomicBool::new(false));
record(Event::Stalled(ms(3_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH);
let path = report_path(dir.path());
// Windows refuses reads through another handle while the file is locked: the length is read.
let last = shared.last.lock().unwrap().clone().unwrap_or_default();
assert!(last.contains("Stage: after a frame with input went to the renderer"));
assert_eq!(std::fs::metadata(&path).unwrap().len(), last.len() as u64);
// While the stall lasts, a launch of another instance leaves the file alone.
assert_eq!(take_previous(dir.path()), None);
assert!(path.exists());
record(Event::Answered(ms(4_000)), s, &beacon, &shared, Some(dir.path()), &mut held, &open, UNIX_EPOCH);
assert!(!path.exists());
// This session's report stays for Copy Troubleshooting Info.
assert!(shared.last.lock().unwrap().is_some());
}
#[test]
fn closing_normally_removes_the_report_and_writes_no_more() {
let dir = tempfile::tempdir().unwrap();
let shared = shared(None);
let beacon = Beacon::new();
let (mut held, flag) = (None, AtomicBool::new(false));
record(Event::Stalled(ms(3_000)), sample(4, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH);
let path = report_path(dir.path());
assert!(path.exists());
// The interface answered after a frame with input, and the app quit before the watching thread
// looked again: saving and teardown are not timed.
beacon.enter(Stage::Drawing);
closing(Some(dir.path()), &flag, &beacon);
assert_eq!(beacon.sample().stage, Stage::Idle);
assert!(!path.exists());
assert_eq!(take_previous(dir.path()), None);
// The watching thread then sees the answer.
record(Event::Answered(ms(3_500)), sample(5, Stage::Idle), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH);
assert!(held.is_none());
// A stall the watching thread finds while the app closes goes to the log only.
record(Event::Stalled(ms(3_000)), sample(9, Stage::Interface), &beacon, &shared, Some(dir.path()), &mut held, &flag, UNIX_EPOCH);
assert!(!path.exists());
assert!(held.is_none());
}
#[test]
fn the_next_launch_takes_a_left_report_once_and_keeps_a_copy() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(take_previous(dir.path()), None);
let left = dir.path().join(format!("{REPORT_PREFIX}4242.txt"));
std::fs::write(&left, "VectorCraft hang report, then\n").unwrap();
// Other files in the log folder are not reports.
std::fs::write(dir.path().join(format!("{REPORT_PREFIX}notes.txt")), "x").unwrap();
std::fs::write(dir.path().join(format!("{REPORT_PREFIX}17.txt")), "").unwrap();
assert_eq!(take_previous(dir.path()).as_deref(), Some("VectorCraft hang report, then\n"));
assert!(!left.exists());
assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "VectorCraft hang report, then\n");
assert_eq!(take_previous(dir.path()), None);
// An empty file may be one being written: it stays.
assert!(dir.path().join(format!("{REPORT_PREFIX}17.txt")).exists());
}
#[test]
fn of_several_left_reports_the_newest_is_kept_and_all_are_offered_oldest_first() {
let dir = tempfile::tempdir().unwrap();
for (pid, text, secs) in [(9, "newer\n", 2_000), (10, "older\n", 1_000)] {
let path = dir.path().join(format!("{REPORT_PREFIX}{pid}.txt"));
std::fs::write(&path, text).unwrap();
let file = std::fs::File::options().write(true).open(&path).unwrap();
file.set_modified(UNIX_EPOCH + Duration::from_secs(secs)).unwrap();
}
assert_eq!(take_previous(dir.path()).as_deref(), Some("older\n\nnewer\n"));
assert_eq!(std::fs::read_to_string(dir.path().join(PREVIOUS_FILE)).unwrap(), "newer\n");
}
#[test]
fn a_report_that_cannot_be_moved_is_offered_once() {
let dir = tempfile::tempdir().unwrap();
// Something that is not a file is in the way.
std::fs::create_dir_all(dir.path().join(PREVIOUS_FILE).join("x")).unwrap();
let left = dir.path().join(format!("{REPORT_PREFIX}5.txt"));
std::fs::write(&left, "kept\n").unwrap();
assert_eq!(take_previous(dir.path()).as_deref(), Some("kept\n"));
assert!(!left.exists());
assert_eq!(take_previous(dir.path()), None);
}
#[test]
fn reports_beyond_the_newest_are_removed_not_left_for_a_later_launch() {
let dir = tempfile::tempdir().unwrap();
let count = MAX_REPORT_FILES + 3;
for n in 0..count {
let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n));
std::fs::write(&path, format!("report {n}\n")).unwrap();
let file = std::fs::File::options().write(true).open(&path).unwrap();
file.set_modified(UNIX_EPOCH + Duration::from_secs(1_000 + n as u64)).unwrap();
}
let text = take_previous(dir.path()).unwrap();
assert!(text.starts_with("report 3\n"), "{text}");
assert!(text.ends_with(&format!("report {}\n", count - 1)), "{text}");
assert!(!text.contains("report 2\n"));
assert_eq!(take_previous(dir.path()), None);
let left: Vec<_> = std::fs::read_dir(dir.path()).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect();
assert_eq!(left, vec![std::ffi::OsString::from(PREVIOUS_FILE)]);
}
#[test]
fn newer_empty_files_do_not_push_out_an_abandoned_report() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join(format!("{REPORT_PREFIX}1.txt"));
std::fs::write(&old, "old\n").unwrap();
std::fs::File::options().write(true).open(&old).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(1_000)).unwrap();
for n in 0..MAX_REPORT_FILES + 1 {
let path = dir.path().join(format!("{REPORT_PREFIX}{}.txt", 100 + n));
std::fs::write(&path, "").unwrap();
std::fs::File::options().write(true).open(&path).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(2_000)).unwrap();
}
assert_eq!(take_previous(dir.path()).as_deref(), Some("old\n"));
}
#[test]
fn a_file_system_without_locks_does_not_hide_a_report() {
use std::io::{Error, ErrorKind};
assert!(abandoned(Ok(())));
assert!(!abandoned(Err(std::fs::TryLockError::WouldBlock)));
assert!(abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::Unsupported)))));
// Any other error may hide a live session's lock: the file is left alone.
assert!(!abandoned(Err(std::fs::TryLockError::Error(Error::from(ErrorKind::PermissionDenied)))));
}
#[test]
fn an_oversized_report_file_is_not_read() {
let dir = tempfile::tempdir().unwrap();
let big = vec![b'x'; usize::try_from(MAX_REPORT_BYTES).unwrap() + 1];
let left = dir.path().join(format!("{REPORT_PREFIX}7.txt"));
std::fs::write(&left, big).unwrap();
let text = take_previous(dir.path()).unwrap();
assert!(text.starts_with("The report was larger than"));
assert!(!left.exists());
}
#[test]
fn troubleshooting_info_holds_both_sessions_reports() {
let shared = shared(Some("VectorCraft hang report, last session\n".to_string()));
*shared.last.lock().unwrap() = Some("VectorCraft hang report, this session\n".to_string());
let beacon = Beacon::new();
let text = troubleshooting(&shared, &beacon, UNIX_EPOCH);
assert!(text.starts_with("VectorCraft troubleshooting info, 1970-01-01T00:00:00.000Z\n"));
assert!(text.contains("This session's last hang report:\nVectorCraft hang report, this session\n"));
assert!(text.contains("(it was closed while it did not answer):\nVectorCraft hang report, last session\n"));
assert!(text.contains("Recent commands, oldest first: none"));
}
+27 -3
View File
@@ -120,7 +120,6 @@ fn make_folder(request: &PickRequest) {
#[cfg(not(target_os = "macos"))]
#[cfg_attr(windows, allow(unused_variables))]
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent) -> Vec<String> {
make_folder(request);
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
if let PickRequest::Save(pick) = request
&& let Some(answer) = portal_save::pick(pick, parent.as_deref())
@@ -139,7 +138,30 @@ fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest, parent: &Parent)
/// Show the dialog for `request` over `parent` now → the paths picked.
#[cfg(not(target_os = "macos"))]
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request, parent)
let beacon = &vectorcraft_ui_egui::hang_beacon::BEACON;
marked_dialog(beacon, || make_folder(&request), || show_dialog(file_dialog(&request, parent), &request, parent))
}
/// Run `prepare` (making the dialog's folder), then `show`, marked on `beacon` as a dialog: the
/// dialog waits for the user and is not a stall for the hang report (`hang_watch`), while `prepare`
/// is still timed.
#[cfg(not(target_os = "macos"))]
fn marked_dialog<T>(beacon: &vectorcraft_ui_egui::hang_beacon::Beacon, prepare: impl FnOnce(), show: impl FnOnce() -> T) -> T {
prepare();
let _mark = beacon.activity(vectorcraft_ui_egui::hang_beacon::Activity::Dialog);
show()
}
#[cfg(all(test, not(target_os = "macos")))]
#[test]
fn making_a_dialogs_folder_is_timed_and_the_dialog_is_not() {
use vectorcraft_ui_egui::hang_beacon::{Activity, Beacon};
let beacon = Beacon::new();
let mut preparing = None;
let showing = marked_dialog(&beacon, || preparing = Some(beacon.sample().activity), || beacon.sample().activity);
assert_eq!(preparing, Some(Activity::None), "making the folder is timed");
assert_eq!(showing, Activity::Dialog, "the dialog is not");
assert_eq!(beacon.sample().activity, Activity::None, "the mark ends with the dialog");
}
/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an
@@ -239,6 +261,7 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
make_folder(&request);
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request, &parent));
ui.request_repaint();
@@ -363,7 +386,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
})),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard(clip)),
// Its calls are marked for the hang report (`hang_watch`).
system_clipboard: Some(Box::new(vectorcraft_ui_egui::hang_beacon::Marked(clipboard::system_clipboard(clip)))),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
// clipboard holds and macOS asks the pasteboard server, so they check in line.
+42
View File
@@ -30,6 +30,7 @@ mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
mod hang_watch;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
@@ -123,6 +124,8 @@ struct App {
held_files: Vec<String>,
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
monitors_listed: Option<(f64, f32)>,
/// When this frame's input hook started, for the hang report's frame times (`hang_watch`).
frame_start: Option<std::time::Instant>,
}
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
@@ -293,6 +296,11 @@ fn main() -> std::process::ExitCode {
None => logger.no_file(),
}
}
// The report a session left when it was closed while it did not answer (`hang_watch`).
let previous_hang = log_dir().filter(|_| prefs_enabled()).and_then(|dir| hang_watch::take_previous(&dir));
if let Some(report) = &previous_hang {
log::warn!("the last session was closed while it did not answer; its report:\n{}", report.trim_end());
}
// The roots confine the control channel: a bad one keeps the app from starting rather than
// leaving agents unconfined. Without a control port there is nothing to confine.
let roots = if control_port.is_none() {
@@ -437,6 +445,9 @@ fn main() -> std::process::ExitCode {
open_documents::install();
#[cfg(feature = "wgpu")]
let created = startup.clone();
// For the hang report (`hang_watch`).
#[cfg(feature = "wgpu")]
let requested_surface = options.wgpu_options.surface;
// A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash.
let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| {
#[cfg(target_os = "linux")]
@@ -537,6 +548,36 @@ fn main() -> std::process::ExitCode {
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
// The hang report and Help › Copy Troubleshooting Info (`hang_watch`).
// The surface settings the options asked for; the driver resolves the present mode.
#[cfg(feature = "wgpu")]
let surface = cc
.wgpu_render_state
.as_ref()
.map(|_| (format!("{:?}", requested_surface.present_mode), requested_surface.desired_maximum_frame_latency));
#[cfg(not(feature = "wgpu"))]
let surface = None;
let hang = std::sync::Arc::new(hang_watch::Shared {
facts: hang_watch::Facts::new(
|name| std::env::var(name).ok(),
std::path::Path::new("/.flatpak-info").exists(),
hang_watch::window_system(winit_window.as_deref()),
app.graphics_adapter.clone(),
hang_watch::surface(surface),
),
started: std::time::Instant::now(),
last: std::sync::Mutex::new(None),
previous: previous_hang.clone(),
});
vectorcraft_ui_egui::hang_beacon::watch_input(&vectorcraft_ui_egui::hang_beacon::BEACON, &cc.egui_ctx);
hang_watch::start(log_dir().filter(|_| prefs_enabled()), hang.clone());
app.services.troubleshooting = Some(Box::new(move || {
hang_watch::troubleshooting(&hang, &vectorcraft_ui_egui::hang_beacon::BEACON, std::time::SystemTime::now())
}));
// A message about the graphics processor, or a stopped plug-in, comes first.
if previous_hang.is_some() && app.ui.status.is_empty() {
vectorcraft_ui_egui::troubleshooting::last_session_stopped(&mut app);
}
app.titlebar_from_env = titlebar_from_env();
app.custom_titlebar = custom_titlebar;
app.desktop_frame = !custom_titlebar;
@@ -583,6 +624,7 @@ fn main() -> std::process::ExitCode {
maximize_tries: 8,
held_files,
monitors_listed: None,
frame_start: None,
}))
}),
)
+6
View File
@@ -748,6 +748,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)",
),
(
"help.copyTroubleshootingInfo",
"Copy Troubleshooting Info",
"",
"{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}",
),
("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"),
(
"ui.swatchOptions",
+1
View File
@@ -135,6 +135,7 @@ impl VectorcraftApp {
last_window_title: String::new(),
fullscreen_for_mode: false,
graphics_adapter: None,
beacon: &crate::hang_beacon::BEACON,
place: Default::default(),
system_paste: false,
clipboard_image_request: None,
+1
View File
@@ -73,6 +73,7 @@ fn substitute_warnings_follow_current_font_inventory() {
impl VectorcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
self.beacon.command(id);
let touch_workspace = self.session.prefs.touch_workspace;
// A file dialog it shows off the UI thread runs it again with the path picked.
let result = if self.picks.is_entry_free() {
+674
View File
@@ -0,0 +1,674 @@
//! Where the interface thread is in its frame, for the desktop app's hang report (the app's
//! `hang_watch` module).
//!
//! The interface thread marks each stage of a frame (the host's input hook, the app's logic, the
//! interface, and the hand-off to the renderer), a system clipboard call in progress, whether any
//! window's pass had input ([`watch_input`]), and each command it runs. Another thread reads the
//! marks without waiting for the interface thread: the stage and the activity are atomics, and the
//! recent commands and frame times are read with `try_lock`. Marking costs a few atomic stores a
//! frame and one short lock per command and per frame. The web build marks the same way, and
//! nothing reads the marks there.
//!
//! Commands are kept by id only: no parameters, file names or document contents. A command run
//! again moves to the end of the list instead of taking another place, so a panel that runs a
//! query every frame does not push out the commands before it.
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Mutex, PoisonError};
use vectorcraft_engine::cmd::clipboard::Flavour;
use crate::sysclip::{ClipboardRead, SystemClipboard};
/// How many different command ids [`Beacon::recent_commands`] keeps.
pub const RECENT_COMMANDS: usize = 8;
/// How many frames [`Beacon::recent_frames`] keeps.
pub const RECENT_FRAMES: usize = 16;
/// The longest command id [`Beacon::command`] keeps as it is.
pub const MAX_COMMAND_ID: usize = 64;
/// The marks the desktop app's interface thread writes.
pub static BEACON: Beacon = Beacon::new();
/// The part of a frame the interface thread is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum Stage {
/// Outside the app's own frame code: waiting for input or a repaint, or a frame without input
/// handed to the renderer.
Idle = 0,
/// The host's input hook.
Input = 1,
/// The app's logic: background results, timers, the control channel.
Logic = 2,
/// The interface: input handling, commands, panels and dialogs.
Interface = 3,
/// A frame that had input was handed to the renderer. Drawing and presenting it follow, and
/// then the next frame, which input asks for at once.
Drawing = 4,
}
impl Stage {
fn from_u8(n: u8) -> Stage {
match n {
1 => Stage::Input,
2 => Stage::Logic,
3 => Stage::Interface,
4 => Stage::Drawing,
_ => Stage::Idle,
}
}
/// Whether the interface thread is expected to finish this stage soon.
pub fn busy(self) -> bool {
self != Stage::Idle
}
/// The stage for a report, in English.
pub fn describe(self) -> &'static str {
match self {
Stage::Idle => "between frames",
Stage::Input => "reading the window's input",
Stage::Logic => "running the app's logic (background results, timers, the control channel)",
Stage::Interface => "running the interface (input handling, commands, panels and dialogs)",
Stage::Drawing => {
"after a frame with input went to the renderer: drawing or presenting it, or waiting for the next frame, which the system may hold back while the window is hidden"
}
}
}
}
/// What the interface thread is waiting on inside a stage, where the app knows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum Activity {
None = 0,
/// A read of the system clipboard, or a check of what it holds.
ClipboardRead = 1,
/// A write to the system clipboard.
ClipboardWrite = 2,
/// A system file dialog shown in line, which waits for the user and keeps the window
/// answering the system ([`Sample::busy`]).
Dialog = 3,
/// A menu of the system menu bar is open, which waits for the user ([`Sample::busy`]). The
/// host sets it on the sample it reads; the interface thread does not mark it.
Menu = 4,
}
impl Activity {
fn from_u8(n: u8) -> Activity {
match n {
1 => Activity::ClipboardRead,
2 => Activity::ClipboardWrite,
3 => Activity::Dialog,
4 => Activity::Menu,
_ => Activity::None,
}
}
/// Whether the activity waits for the user, so a long one is not a stall.
pub fn waits_for_user(self) -> bool {
matches!(self, Activity::Dialog | Activity::Menu)
}
/// The activity for a report, in English; `None` when there is none.
pub fn describe(self) -> Option<&'static str> {
match self {
Activity::None => None,
Activity::ClipboardRead => Some("reading the system clipboard"),
Activity::ClipboardWrite => Some("writing to the system clipboard"),
Activity::Dialog => Some("a system file dialog, waiting for the user"),
Activity::Menu => Some("a menu of the system menu bar, waiting for the user"),
}
}
}
/// What the interface thread was doing when it was read.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Sample {
/// Counts every mark: the same value in two samples means the thread made no mark between them.
pub step: u64,
pub stage: Stage,
pub activity: Activity,
}
impl Sample {
/// Whether the interface thread is expected to move on soon: inside a stage, and not showing a
/// system dialog or menu that waits for the user.
pub fn busy(&self) -> bool {
self.stage.busy() && !self.activity.waits_for_user()
}
}
/// One frame's times, in milliseconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FrameTime {
/// The app's own work: from the input hook to the end of the interface.
pub work_ms: f32,
/// The renderer's measure of the frame before: the app's work and drawing, without waiting for
/// the display. `None` when the host has none.
pub previous_total_ms: Option<f32>,
}
/// The marks: see the module documentation.
pub struct Beacon {
step: AtomicU64,
stage: AtomicU8,
activity: AtomicU8,
documents: AtomicUsize,
/// A pass of some window had input since [`Beacon::take_input`] last ran.
input: AtomicBool,
/// The windows a pass handed to the system since [`Beacon::take_handed_off`] last ran. Only the
/// interface thread uses it.
handed_off: Mutex<Vec<egui::ViewportId>>,
commands: Mutex<VecDeque<String>>,
frames: Mutex<VecDeque<FrameTime>>,
}
impl Default for Beacon {
fn default() -> Self {
Self::new()
}
}
impl Beacon {
pub const fn new() -> Beacon {
Beacon {
step: AtomicU64::new(0),
stage: AtomicU8::new(Stage::Idle as u8),
activity: AtomicU8::new(Activity::None as u8),
documents: AtomicUsize::new(0),
input: AtomicBool::new(false),
handed_off: Mutex::new(Vec::new()),
commands: Mutex::new(VecDeque::new()),
frames: Mutex::new(VecDeque::new()),
}
}
fn bump(&self) {
self.step.fetch_add(1, Ordering::Release);
}
/// The interface thread enters `stage`.
pub fn enter(&self, stage: Stage) {
self.stage.store(stage as u8, Ordering::Relaxed);
self.bump();
}
/// The interface thread enters `stage` of a frame, timed whatever the activity around it, so a
/// frame the system runs while a file dialog waits for the user is timed too. The activity
/// before it applies again when the guard is dropped.
pub fn enter_timed(&self, stage: Stage) -> ActivityGuard<'_> {
self.enter(stage);
self.activity(Activity::None)
}
/// The interface thread starts `activity`; it ends when the guard is dropped, and the activity
/// before it applies again.
pub fn activity(&self, activity: Activity) -> ActivityGuard<'_> {
let before = Activity::from_u8(self.activity.swap(activity as u8, Ordering::Relaxed));
self.bump();
ActivityGuard { beacon: self, before }
}
/// The interface thread runs the command `id`. An id that is not a command id's shape (longer
/// than [`MAX_COMMAND_ID`] characters, or with characters other than ASCII letters, digits, `.`,
/// `_` and `-`) is kept as `(other)`: ids can come from saved files and the control channel. An
/// id already in the list moves to its end.
pub fn command(&self, id: &str) {
self.bump();
let shaped = id.len() <= MAX_COMMAND_ID && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
let kept = if shaped { id } else { "(other)" };
let mut commands = self.commands.lock().unwrap_or_else(PoisonError::into_inner);
if commands.back().is_some_and(|last| last == kept) {
return;
}
let entry = match commands.iter().position(|c| c == kept) {
Some(i) => commands.remove(i).unwrap_or_else(|| kept.to_string()),
None => {
if commands.len() == RECENT_COMMANDS {
commands.pop_front();
}
kept.to_string()
}
};
commands.push_back(entry);
}
/// A pass of some window had input ([`watch_input`]).
pub fn input(&self) {
self.input.store(true, Ordering::Relaxed);
}
/// Whether a pass of some window had input since the last call.
pub fn take_input(&self) -> bool {
self.input.swap(false, Ordering::Relaxed)
}
/// A pass of window `id` sent it commands that hand it to the system ([`hands_off`],
/// [`watch_input`]).
pub fn hand_off(&self, id: egui::ViewportId) {
let mut windows = self.handed_off.lock().unwrap_or_else(PoisonError::into_inner);
if !windows.contains(&id) {
windows.push(id);
}
}
/// The windows a pass handed to the system since the last call.
pub fn take_handed_off(&self) -> Vec<egui::ViewportId> {
std::mem::take(&mut *self.handed_off.lock().unwrap_or_else(PoisonError::into_inner))
}
/// A frame ended with `time`, with `documents` documents open.
pub fn frame(&self, time: FrameTime, documents: usize) {
self.documents.store(documents, Ordering::Relaxed);
let mut frames = self.frames.lock().unwrap_or_else(PoisonError::into_inner);
if frames.len() == RECENT_FRAMES {
frames.pop_front();
}
frames.push_back(time);
}
/// The current marks.
pub fn sample(&self) -> Sample {
let step = self.step.load(Ordering::Acquire);
Sample { step, stage: Stage::from_u8(self.stage.load(Ordering::Relaxed)), activity: Activity::from_u8(self.activity.load(Ordering::Relaxed)) }
}
/// The ids of the last [`RECENT_COMMANDS`] commands, oldest first; `None` while the interface
/// thread is writing them.
pub fn recent_commands(&self) -> Option<Vec<String>> {
let commands = match self.commands.try_lock() {
Ok(c) => c,
Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
Some(commands.iter().cloned().collect())
}
/// The last [`RECENT_FRAMES`] frames' times, oldest first; `None` while the interface thread is
/// writing them.
pub fn recent_frames(&self) -> Option<Vec<FrameTime>> {
let frames = match self.frames.try_lock() {
Ok(f) => f,
Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
Some(frames.iter().copied().collect())
}
/// The documents open at the end of the last frame.
pub fn documents(&self) -> usize {
self.documents.load(Ordering::Relaxed)
}
}
/// Whether a pass's input asks for the next frame at once: events, or files dropped or hovered.
pub fn had_input(raw: &egui::RawInput) -> bool {
!raw.events.is_empty() || !raw.dropped_files.is_empty() || !raw.hovered_files.is_empty()
}
/// Mark on `beacon` every pass of every window of `ctx` that had input ([`had_input`]): the main
/// window's, after the host's input hook added its own, and those of the windows shown in line
/// (floating panels and documents), whose input does not pass through that hook. Mark as well every
/// pass of a window shown in line that sends its window commands that hand it to the system
/// ([`hands_off`]), before egui passes them on; the main window's are read at the frame's end.
pub fn watch_input(beacon: &'static Beacon, ctx: &egui::Context) {
ctx.on_begin_pass(
"VectorCraft hang report input",
std::sync::Arc::new(move |ui| {
if ui.ctx().input(|i| had_input(&i.raw)) {
beacon.input();
}
}),
);
ctx.on_end_pass(
"VectorCraft hang report hand-off",
std::sync::Arc::new(move |ui| {
if ui.ctx().viewport_id() != egui::ViewportId::ROOT && ui.ctx().viewport(|vp| hands_off(&vp.commands)) {
beacon.hand_off(ui.ctx().viewport_id());
}
}),
);
}
/// Whether `commands` minimize or hide their window, or hand it to the system to move or resize:
/// the system may hold back the next frame until the user is done.
pub fn hands_off(commands: &[egui::ViewportCommand]) -> bool {
use egui::ViewportCommand as C;
commands.iter().any(|c| matches!(c, C::Minimized(true) | C::Visible(false) | C::StartDrag | C::BeginResize(_)))
}
/// The window whose end of frame sets the stage ([`after_frame`]): the main window when it is
/// `main_shown`; otherwise a window shown in line (a floating panel or document) that `viewports`
/// (the main window's input) gives as focused and not minimized; otherwise none.
pub fn shown_window(main_shown: bool, viewports: &egui::ViewportIdMap<egui::ViewportInfo>) -> Option<egui::ViewportId> {
if main_shown {
return Some(egui::ViewportId::ROOT);
}
viewports.iter().find(|(id, v)| **id != egui::ViewportId::ROOT && v.focused == Some(true) && v.minimized != Some(true)).map(|(id, _)| *id)
}
/// The stage at the end of a frame: [`Stage::Drawing`] when a pass had `input` and a window is
/// `shown` (focused, not minimized or hidden: [`shown_window`]), since input asks for the next
/// frame at once; otherwise [`Stage::Idle`]. A frame that sends that window `commands` that
/// minimize or hide it, or hand it to the system to move or resize ([`hands_off`]), also ends idle.
pub fn after_frame(input: bool, shown: bool, commands: &[egui::ViewportCommand]) -> Stage {
if input && shown && !hands_off(commands) { Stage::Drawing } else { Stage::Idle }
}
/// Ends an [`Activity`] when dropped ([`Beacon::activity`]).
pub struct ActivityGuard<'a> {
beacon: &'a Beacon,
before: Activity,
}
impl Drop for ActivityGuard<'_> {
fn drop(&mut self) {
self.beacon.activity.store(self.before as u8, Ordering::Relaxed);
self.beacon.bump();
}
}
/// A [`SystemClipboard`] whose calls are marked on [`BEACON`] as clipboard activity. The desktop
/// app wraps the clipboard the interface thread uses; the background probe's own clipboard stays
/// unmarked.
pub struct Marked(pub Box<dyn SystemClipboard>);
impl SystemClipboard for Marked {
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
let _mark = BEACON.activity(Activity::ClipboardWrite);
self.0.write(flavours)
}
fn holds_ours(&mut self) -> bool {
let _mark = BEACON.activity(Activity::ClipboardRead);
self.0.holds_ours()
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
let _mark = BEACON.activity(Activity::ClipboardRead);
self.0.read(mimes)
}
fn has(&mut self, mimes: &[&'static str]) -> bool {
let _mark = BEACON.activity(Activity::ClipboardRead);
self.0.has(mimes)
}
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
let _mark = BEACON.activity(Activity::ClipboardRead);
self.0.read_deferred(mimes)
}
fn change_count(&mut self) -> Option<u64> {
let _mark = BEACON.activity(Activity::ClipboardRead);
self.0.change_count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_mark_moves_the_step_and_the_stage_reads_back() {
let b = Beacon::new();
let first = b.sample();
assert_eq!((first.stage, first.activity), (Stage::Idle, Activity::None));
b.enter(Stage::Interface);
let s = b.sample();
assert!(s.step > first.step);
assert_eq!(s.stage, Stage::Interface);
b.command("edit.copy");
assert!(b.sample().step > s.step);
b.enter(Stage::Idle);
assert!(!b.sample().stage.busy());
}
#[test]
fn an_activity_ends_with_its_guard_and_restores_the_one_before() {
let b = Beacon::new();
{
let _write = b.activity(Activity::ClipboardWrite);
{
let _read = b.activity(Activity::ClipboardRead);
assert_eq!(b.sample().activity, Activity::ClipboardRead);
}
assert_eq!(b.sample().activity, Activity::ClipboardWrite);
}
assert_eq!(b.sample().activity, Activity::None);
}
#[test]
fn only_the_last_commands_and_frames_are_kept_oldest_first() {
let b = Beacon::new();
for n in 0..RECENT_COMMANDS + 3 {
b.command(&format!("cmd.{n}"));
}
let commands = b.recent_commands().unwrap_or_default();
assert_eq!(commands.len(), RECENT_COMMANDS);
assert_eq!(commands.first().map(String::as_str), Some("cmd.3"));
assert_eq!(commands.last().map(String::as_str), Some("cmd.10"));
for n in 0..RECENT_FRAMES + 2 {
b.frame(FrameTime { work_ms: n as f32, previous_total_ms: None }, 2);
}
let frames = b.recent_frames().unwrap_or_default();
assert_eq!(frames.len(), RECENT_FRAMES);
assert_eq!(frames.first().map(|f| f.work_ms), Some(2.0));
assert_eq!(b.documents(), 2);
}
#[test]
fn a_command_run_every_frame_does_not_push_out_the_ones_before() {
let b = Beacon::new();
for n in 0..RECENT_COMMANDS - 2 {
b.command(&format!("cmd.{n}"));
}
// Two panels that each run a query every frame.
for _ in 0..100 {
b.command("symbol.list");
b.command("swatch.list");
}
b.command("cmd.0");
let commands = b.recent_commands().unwrap_or_default();
let want: Vec<String> = ["cmd.1", "cmd.2", "cmd.3", "cmd.4", "cmd.5", "symbol.list", "swatch.list", "cmd.0"].map(String::from).to_vec();
assert_eq!(commands, want);
}
#[test]
fn a_frame_with_input_ends_drawing_unless_the_window_is_hidden_or_handed_to_the_system() {
use egui::ViewportCommand as C;
assert_eq!(after_frame(true, true, &[]), Stage::Drawing);
assert_eq!(after_frame(true, true, &[C::Focus]), Stage::Drawing);
assert_eq!(after_frame(false, true, &[]), Stage::Idle);
assert_eq!(after_frame(true, false, &[]), Stage::Idle);
for c in [C::Minimized(true), C::Visible(false), C::StartDrag, C::BeginResize(egui::ResizeDirection::East)] {
assert_eq!(after_frame(true, true, std::slice::from_ref(&c)), Stage::Idle, "{c:?}");
}
}
#[test]
fn input_is_events_or_files() {
let mut raw = egui::RawInput::default();
assert!(!had_input(&raw));
raw.hovered_files.push(egui::HoveredFile::default());
assert!(had_input(&raw));
#[derive(Debug)]
struct Dropped;
impl egui::DroppedFile for Dropped {
fn path(&self) -> &std::path::Path {
std::path::Path::new("a.svg")
}
fn bytes(&self) -> Result<Vec<u8>, String> {
Ok(Vec::new())
}
}
let mut raw = egui::RawInput::default();
raw.dropped_files.push(std::sync::Arc::new(Dropped));
assert!(had_input(&raw));
let mut raw = egui::RawInput::default();
raw.events.push(egui::Event::PointerGone);
assert!(had_input(&raw));
}
#[test]
fn input_in_a_window_shown_in_line_is_marked() {
static B: Beacon = Beacon::new();
// The host runs a window shown in line as its own pass, with that window's input.
let child_events = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let events = child_events.clone();
egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| {
let id = viewport.ids.this;
let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect();
let input = egui::RawInput { viewport_id: id, viewports, events: events.take(), ..Default::default() };
ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear();
});
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
watch_input(&B, &ctx);
let child = egui::ViewportId::from_hash_of("floating panel");
let frame = |main: Vec<egui::Event>| {
ctx.run_ui(egui::RawInput { events: main, ..Default::default() }, |ui| {
ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |_, _| {});
})
.textures_delta
.clear();
};
frame(Vec::new());
assert!(!B.take_input(), "no input anywhere");
child_events.replace(vec![egui::Event::PointerGone]);
frame(Vec::new());
assert!(B.take_input(), "the floating window's input");
assert!(!B.take_input(), "taken");
frame(vec![egui::Event::PointerGone]);
assert!(B.take_input(), "the main window's input");
}
#[test]
fn a_frame_run_while_a_dialog_waits_is_timed() {
let b = Beacon::new();
let _dialog = b.activity(Activity::Dialog);
assert!(!b.sample().busy(), "the dialog waits for the user");
{
let _timed = b.enter_timed(Stage::Interface);
assert!(b.sample().busy(), "a frame inside the dialog's wait");
b.enter(Stage::Drawing);
}
assert_eq!(b.sample().activity, Activity::Dialog, "the dialog again");
assert!(!b.sample().busy());
}
#[test]
fn a_window_shown_in_line_handed_to_the_system_is_marked() {
static B: Beacon = Beacon::new();
egui::Context::set_immediate_viewport_renderer(|ctx, mut viewport| {
let id = viewport.ids.this;
let viewports = std::iter::once((id, egui::ViewportInfo::default())).collect();
let input = egui::RawInput { viewport_id: id, viewports, ..Default::default() };
ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)).textures_delta.clear();
});
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
watch_input(&B, &ctx);
let child = egui::ViewportId::from_hash_of("floating panel");
let frame = |command: Option<egui::ViewportCommand>, main: Option<egui::ViewportCommand>| {
ctx.run_ui(egui::RawInput::default(), |ui| {
ui.ctx().show_viewport_immediate(child, egui::ViewportBuilder::default(), |ui, _| {
if let Some(c) = command.clone() {
ui.ctx().send_viewport_cmd(c);
}
});
if let Some(c) = main.clone() {
ui.ctx().send_viewport_cmd(c);
}
})
.textures_delta
.clear();
};
frame(None, None);
assert!(B.take_handed_off().is_empty(), "nothing handed off");
frame(Some(egui::ViewportCommand::StartDrag), None);
assert_eq!(B.take_handed_off(), [child], "the floating window starts a move");
assert!(B.take_handed_off().is_empty(), "taken");
frame(Some(egui::ViewportCommand::Focus), None);
assert!(B.take_handed_off().is_empty(), "another command");
frame(None, Some(egui::ViewportCommand::StartDrag));
assert!(B.take_handed_off().is_empty(), "the main window's commands are read at the frame's end");
}
#[test]
fn a_focused_window_shown_in_line_is_the_shown_window() {
use egui::{ViewportId, ViewportIdMap, ViewportInfo};
let child = ViewportId::from_hash_of("document window");
let info = |focused: bool, minimized: bool| ViewportInfo { focused: Some(focused), minimized: Some(minimized), ..Default::default() };
let map = |root: ViewportInfo, window: ViewportInfo| -> ViewportIdMap<ViewportInfo> {
[(ViewportId::ROOT, root), (child, window)].into_iter().collect()
};
assert_eq!(shown_window(true, &map(info(true, false), info(false, false))), Some(ViewportId::ROOT));
assert_eq!(shown_window(false, &map(info(false, false), info(true, false))), Some(child), "the document window has the focus");
assert_eq!(shown_window(false, &map(info(false, false), info(true, true))), None, "minimized");
assert_eq!(shown_window(false, &map(info(false, false), info(false, false))), None, "no window has the focus");
assert_eq!(shown_window(false, &map(info(true, false), info(false, false))), None, "the main window is not shown");
}
#[test]
fn the_marked_clipboard_marks_its_calls() {
type Seen = std::sync::Arc<Mutex<Vec<Activity>>>;
struct Probe(Seen);
impl Probe {
fn see(&self) {
self.0.lock().unwrap().push(BEACON.sample().activity);
}
}
impl SystemClipboard for Probe {
fn write(&mut self, _: &[Flavour]) -> Result<(), String> {
self.see();
Ok(())
}
fn holds_ours(&mut self) -> bool {
self.see();
false
}
fn read(&mut self, _: &[&'static str]) -> Option<Flavour> {
self.see();
None
}
fn has(&mut self, _: &[&'static str]) -> bool {
self.see();
false
}
}
let seen = Seen::default();
let mut m = Marked(Box::new(Probe(seen.clone())));
let _ = m.write(&[]);
let _ = m.read(&[]);
let _ = m.has(&[]);
let _ = m.holds_ours();
assert_eq!(BEACON.sample().activity, Activity::None);
assert_eq!(*seen.lock().unwrap(), vec![Activity::ClipboardWrite, Activity::ClipboardRead, Activity::ClipboardRead, Activity::ClipboardRead]);
}
#[test]
fn ids_that_are_not_command_ids_are_kept_as_other() {
let b = Beacon::new();
b.command("file.open");
b.command("/home/someone/private plan.vectorcraft");
b.command(&"y".repeat(MAX_COMMAND_ID));
b.command(&"x".repeat(MAX_COMMAND_ID + 1));
let commands = b.recent_commands().unwrap_or_default();
// Both are `(other)`, which moves to the end the second time.
assert_eq!(commands, vec!["file.open".to_string(), "y".repeat(MAX_COMMAND_ID), "(other)".into()]);
}
#[test]
fn a_reader_does_not_wait_while_the_commands_are_being_written() {
let b = Beacon::new();
b.command("edit.paste");
let held = b.commands.lock().unwrap_or_else(PoisonError::into_inner);
assert_eq!(b.recent_commands(), None);
drop(held);
assert_eq!(b.recent_commands(), Some(vec!["edit.paste".to_string()]));
}
}
+5
View File
@@ -6562,6 +6562,11 @@ breakText Glyphs Глифи
No importer plug-ins installed Няма инсталирани плъгини за импортиране
Read files you open in its format and make documents from them Четене на файлове, които отваряте в неговия формат, и създаване на документи от тях
# Copy Troubleshooting Info
Copy Troubleshooting Info Копиране на информация за отстраняване на проблеми
@msg Copied the troubleshooting info Информацията за отстраняване на проблеми е копирана
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последната сесия на VectorCraft спря да отговаря, преди да се затвори. Помощ › Копиране на информация за отстраняване на проблеми копира подробностите за доклад за грешка
# Safe Mode
Safe Mode… Безопасен режим…
Safe Mode Безопасен режим
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6613,6 +6613,11 @@ shape Open Otvoren
No importer plug-ins installed Nisu instalirani dodaci za uvoz
Read files you open in its format and make documents from them Čitati datoteke koje otvarate u njegovom formatu i od njih praviti dokumente
# Copy Troubleshooting Info
Copy Troubleshooting Info Kopiraj informacije za rješavanje problema
@msg Copied the troubleshooting info Informacije za rješavanje problema kopirane
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Posljednja sesija VectorCrafta prestala je odgovarati prije zatvaranja. Pomoć › Kopiraj informacije za rješavanje problema kopira detalje za prijavu greške
# Safe Mode
Safe Mode… Sigurni način rada…
Safe Mode Sigurni način rada
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6833,6 +6833,11 @@ breakText Glyphs Glifs
@msg only an exporter or an importer declares `extensions` només un exportador o un importador declara `extensions`
@msg an importer always gets an import's budget: leave `budget` out un importador sempre rep el pressupost d’una importació: ometeu `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Copia la informació de resolució de problemes
@msg Copied the troubleshooting info S’ha copiat la informació de resolució de problemes
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La darrera sessió del VectorCraft va deixar de respondre abans de tancar-se. Ajuda › Copia la informació de resolució de problemes copia els detalls per a un informe d’error
# Safe Mode
Safe Mode… Mode segur…
Safe Mode Mode segur
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6852,6 +6852,11 @@ breakText Glyphs Glyfy
@msg only an exporter or an importer declares `extensions` `extensions` deklaruje jen exportní nebo importní zásuvný modul
@msg an importer always gets an import's budget: leave `budget` out importní zásuvný modul dostává vždy rozpočet importu: vynechte `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Kopírovat informace pro řešení potíží
@msg Copied the troubleshooting info Informace pro řešení potíží zkopírovány
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Poslední relace VectorCraftu přestala odpovídat, než se zavřela. Nápověda › Kopírovat informace pro řešení potíží zkopíruje podrobnosti pro hlášení chyby
# Safe Mode
Safe Mode… Nouzový režim…
Safe Mode Nouzový režim
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6576,6 +6576,11 @@ breakText Glyphs Glyffer
No importer plug-ins installed Ingen import-plug-ins installeret
Read files you open in its format and make documents from them Læse filer, du åbner i dens format, og lave dokumenter ud fra dem
# Copy Troubleshooting Info
Copy Troubleshooting Info Kopiér fejlfindingsoplysninger
@msg Copied the troubleshooting info Kopierede fejlfindingsoplysningerne
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den seneste VectorCraft-session holdt op med at svare, før den blev lukket. Hjælp › Kopiér fejlfindingsoplysninger kopierer detaljerne til en fejlrapport
# Safe Mode
Safe Mode… Sikker tilstand…
Safe Mode Sikker tilstand
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6447,6 +6447,11 @@ breakText Glyphs Glyphen
@msg only an exporter or an importer declares `extensions` nur ein Export- oder Import-Plug-in gibt `extensions` an
@msg an importer always gets an import's budget: leave `budget` out ein Import-Plug-in erhält immer das Budget eines Imports: Lassen Sie `budget` weg
# Copy Troubleshooting Info
Copy Troubleshooting Info Informationen zur Fehlerbehebung kopieren
@msg Copied the troubleshooting info Informationen zur Fehlerbehebung kopiert
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Die letzte VectorCraft-Sitzung reagierte nicht mehr, bevor sie geschlossen wurde. Hilfe › Informationen zur Fehlerbehebung kopieren kopiert die Details für einen Fehlerbericht
# Safe Mode
Safe Mode… Abgesicherter Modus…
Safe Mode Abgesicherter Modus
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6804,6 +6804,11 @@ breakText Glyphs Γλύφοι
@msg only an exporter or an importer declares `extensions` μόνο ένα πρόσθετο εξαγωγής ή εισαγωγής δηλώνει `extensions`
@msg an importer always gets an import's budget: leave `budget` out ένα πρόσθετο εισαγωγής παίρνει πάντα τον προϋπολογισμό μιας εισαγωγής: παραλείψτε το `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων
@msg Copied the troubleshooting info Οι πληροφορίες αντιμετώπισης προβλημάτων αντιγράφηκαν
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Η τελευταία συνεδρία του VectorCraft σταμάτησε να αποκρίνεται πριν κλείσει. Βοήθεια › Αντιγραφή πληροφοριών αντιμετώπισης προβλημάτων αντιγράφει τις λεπτομέρειες για αναφορά σφάλματος
# Safe Mode
Safe Mode… Ασφαλής λειτουργία…
Safe Mode Ασφαλής λειτουργία
Can't render this file because it is too large.
+5
View File
@@ -6795,6 +6795,11 @@ breakText Glyphs Glifos
@msg only an exporter or an importer declares `extensions` solo un plugin de exportación o de importación declara `extensions`
@msg an importer always gets an import's budget: leave `budget` out un plugin de importación siempre recibe el presupuesto de una importación: omita `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Copiar información de solución de problemas
@msg Copied the troubleshooting info Se copió la información de solución de problemas
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La última sesión de VectorCraft dejó de responder antes de cerrarse. Ayuda › Copiar información de solución de problemas copia los detalles para un informe de error
# Safe Mode
Safe Mode… Modo seguro…
Safe Mode Modo seguro
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6887,6 +6887,11 @@ breakText Glyphs Merkit
@msg only an exporter or an importer declares `extensions` vain vienti- tai tuontilaajennus ilmoittaa `extensions`
@msg an importer always gets an import's budget: leave `budget` out tuontilaajennus saa aina tuonnin budjetin: jätä `budget` pois
# Copy Troubleshooting Info
Copy Troubleshooting Info Kopioi vianmääritystiedot
@msg Copied the troubleshooting info Vianmääritystiedot kopioitiin
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraftin edellinen istunto lakkasi vastaamasta ennen kuin se suljettiin. Ohje › Kopioi vianmääritystiedot kopioi tiedot virheraporttia varten
# Safe Mode
Safe Mode… Vikasietotila…
Safe Mode Vikasietotila
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6847,6 +6847,11 @@ breakText Glyphs Glyphes
@msg only an exporter or an importer declares `extensions` seul un module externe d’exportation ou d’importation déclare `extensions`
@msg an importer always gets an import's budget: leave `budget` out un module externe d’importation reçoit toujours le budget d’une importation : omettez `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Copier les informations de dépannage
@msg Copied the troubleshooting info Informations de dépannage copiées
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report La dernière session de VectorCraft a cessé de répondre avant de se fermer. Aide › Copier les informations de dépannage copie les détails pour un rapport de bogue
# Safe Mode
Safe Mode… Mode sans échec…
Safe Mode Mode sans échec
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -8263,6 +8263,11 @@ swatch Yellow पीला
swatch Yellow Green पीला हरा
theme Appearance अपीयरेंस
# Copy Troubleshooting Info
Copy Troubleshooting Info समस्या निवारण जानकारी कॉपी करें
@msg Copied the troubleshooting info समस्या निवारण जानकारी कॉपी की गई
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft के पिछले सत्र ने बंद होने से पहले प्रतिक्रिया देना बंद कर दिया था। सहायता ›समस्या निवारण जानकारी कॉपी करें बग रिपोर्ट के लिए विवरण कॉपी करता है
# Safe Mode
Safe Mode… सुरक्षित मोड…
Safe Mode सुरक्षित मोड
Can't render this file because it is too large.
+5
View File
@@ -6836,6 +6836,11 @@ breakText Glyphs Glifák
@msg only an exporter or an importer declares `extensions` csak exportáló vagy importáló bővítmény adhat meg `extensions` értéket
@msg an importer always gets an import's budget: leave `budget` out az importáló bővítmény mindig egy importálás keretét kapja: hagyja el a `budget` kulcsot
# Copy Troubleshooting Info
Copy Troubleshooting Info Hibaelhárítási adatok másolása
@msg Copied the troubleshooting info Hibaelhárítási adatok másolva
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A VectorCraft legutóbbi munkamenete nem válaszolt, mielőtt bezárult. A Súgó › Hibaelhárítási adatok másolása kimásolja a részleteket egy hibajelentéshez
# Safe Mode
Safe Mode… Csökkentett mód…
Safe Mode Csökkentett mód
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6576,6 +6576,11 @@ breakText Glyphs Glif
No importer plug-ins installed Tidak ada plug-in impor yang terinstal
Read files you open in its format and make documents from them Membaca file yang Anda buka dalam formatnya dan membuat dokumen darinya
# Copy Troubleshooting Info
Copy Troubleshooting Info Salin info pemecahan masalah
@msg Copied the troubleshooting info Berhasil menyalin info pemecahan masalah
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Sesi terakhir VectorCraft berhenti merespons sebelum ditutup. Bantuan › Salin info pemecahan masalah menyalin detailnya untuk laporan bug
# Safe Mode
Safe Mode… Mode aman…
Safe Mode Mode aman
Can't render this file because it contains an unexpected character in line 2366 and column 5.
+5
View File
@@ -6481,6 +6481,11 @@ breakText Glyphs Glifi
@msg only an exporter or an importer declares `extensions` solo un plug-in di esportazione o di importazione dichiara `extensions`
@msg an importer always gets an import's budget: leave `budget` out un plug-in di importazione riceve sempre il budget di un’importazione: ometti `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Copia informazioni per la risoluzione dei problemi
@msg Copied the troubleshooting info Informazioni per la risoluzione dei problemi copiate
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report L’ultima sessione di VectorCraft ha smesso di rispondere prima di chiudersi. Aiuto › Copia informazioni per la risoluzione dei problemi copia i dettagli per una segnalazione di bug
# Safe Mode
Safe Mode… Modalità provvisoria…
Safe Mode Modalità provvisoria
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6546,6 +6546,11 @@ breakText Glyphs 字形
@msg only an exporter or an importer declares `extensions` `extensions` を宣言できるのは書き出しプラグインと読み込みプラグインだけです
@msg an importer always gets an import's budget: leave `budget` out 読み込みプラグインには常に読み込みの予算が割り当てられます: `budget` は省略してください
# Copy Troubleshooting Info
Copy Troubleshooting Info トラブルシューティング情報をコピー
@msg Copied the troubleshooting info トラブルシューティング情報をコピーしました
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 前回の VectorCraft のセッションは、閉じる前に応答しなくなりました。ヘルプ › トラブルシューティング情報をコピー で、バグ報告用の詳細をコピーできます
# Safe Mode
Safe Mode… セーフモード…
Safe Mode セーフモード
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6961,6 +6961,11 @@ font-script Japanese იაპონური
@msg only an exporter or an importer declares `extensions` `extensions`-ს მხოლოდ ექსპორტის ან იმპორტის პლაგინი აცხადებს
@msg an importer always gets an import's budget: leave `budget` out იმპორტის პლაგინი ყოველთვის იღებს იმპორტის ბიუჯეტს: გამოტოვეთ `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info პრობლემების მოგვარების ინფორმაციის კოპირება
@msg Copied the troubleshooting info პრობლემების მოგვარების ინფორმაცია კოპირებულია
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft-ის ბოლო სესიამ დახურვამდე პასუხის გაცემა შეწყვიტა. დახმარება › პრობლემების მოგვარების ინფორმაციის კოპირება აკოპირებს დეტალებს შეცდომის ანგარიშისთვის
# Safe Mode
Safe Mode… უსაფრთხო რეჟიმი…
Safe Mode უსაფრთხო რეჟიმი
Can't render this file because it is too large.
+5
View File
@@ -6442,6 +6442,11 @@ breakText Glyphs 글리프
@msg only an exporter or an importer declares `extensions` `extensions`는 내보내기 또는 가져오기 플러그인만 선언합니다
@msg an importer always gets an import's budget: leave `budget` out 가져오기 플러그인은 항상 가져오기 예산을 받습니다: `budget`을 생략하세요
# Copy Troubleshooting Info
Copy Troubleshooting Info 문제 해결 정보 복사
@msg Copied the troubleshooting info 문제 해결 정보를 복사했습니다
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 지난 VectorCraft 세션이 닫히기 전에 응답을 멈췄습니다. 도움말 › 문제 해결 정보 복사로 버그 보고용 세부 정보를 복사할 수 있습니다
# Safe Mode
Safe Mode… 안전 모드…
Safe Mode 안전 모드
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6627,6 +6627,11 @@ breakText Glyphs Glyphs
No importer plug-ins installed Geen importplug-ins geïnstalleerd
Read files you open in its format and make documents from them Bestanden lezen die u in zijn indeling opent en er documenten van maken
# Copy Troubleshooting Info
Copy Troubleshooting Info Probleemoplossingsgegevens kopiëren
@msg Copied the troubleshooting info Probleemoplossingsgegevens gekopieerd
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report De laatste sessie van VectorCraft reageerde niet meer voordat deze werd gesloten. Help › Probleemoplossingsgegevens kopiëren kopieert de details voor een foutrapport
# Safe Mode
Safe Mode… Veilige modus…
Safe Mode Veilige modus
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6708,6 +6708,11 @@ breakText Glyphs Glify
@msg only an exporter or an importer declares `extensions` `extensions` deklaruje tylko wtyczka eksportu lub importu
@msg an importer always gets an import's budget: leave `budget` out wtyczka importu zawsze dostaje budżet importu: pomiń `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Kopiuj informacje diagnostyczne
@msg Copied the troubleshooting info Skopiowano informacje diagnostyczne
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Ostatnia sesja VectorCraft przestała odpowiadać przed zamknięciem. Pomoc › Kopiuj informacje diagnostyczne kopiuje szczegóły do zgłoszenia błędu
# Safe Mode
Safe Mode… Tryb awaryjny…
Safe Mode Tryb awaryjny
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6925,6 +6925,11 @@ swatch Yellow Green Verde-amarelado
@msg only an exporter or an importer declares `extensions` só um plug-in de exportação ou de importação declara `extensions`
@msg an importer always gets an import's budget: leave `budget` out um plug-in de importação sempre recebe o orçamento de uma importação: omita `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Copiar informações de solução de problemas
@msg Copied the troubleshooting info Informações de solução de problemas copiadas
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report A última sessão do VectorCraft parou de responder antes de fechar. Ajuda › Copiar informações de solução de problemas copia os detalhes para um relatório de bug
# Safe Mode
Safe Mode… Modo de Segurança…
Safe Mode Modo de Segurança
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -6941,6 +6941,11 @@ shape Open Открытый
@msg only an exporter or an importer declares `extensions` `extensions` объявляет только модуль экспорта или импорта
@msg an importer always gets an import's budget: leave `budget` out модуль импорта всегда получает бюджет импорта: не указывайте `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Копировать сведения для устранения неполадок
@msg Copied the troubleshooting info Сведения для устранения неполадок скопированы
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Последний сеанс VectorCraft перестал отвечать перед закрытием. Справка › Копировать сведения для устранения неполадок копирует подробности для отчёта об ошибке
# Safe Mode
Safe Mode… Безопасный режим…
Safe Mode Безопасный режим
Can't render this file because it is too large.
+5
View File
@@ -8332,6 +8332,11 @@ digits Hindi Hindi
@msg an importer declares 1 to {MAX_EXTENSIONS} `extensions` en importfunktion anger 1 till {MAX_EXTENSIONS} `extensions`
@msg an importer always gets an import's budget: leave `budget` out en importfunktion får alltid en imports budget: utelämna `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Kopiera felsökningsinformation
@msg Copied the troubleshooting info Kopierade felsökningsinformationen
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Den senaste VectorCraft-sessionen slutade svara innan den stängdes. Hjälp › Kopiera felsökningsinformation kopierar detaljerna till en felrapport
# Safe Mode
Safe Mode… Felsäkert läge…
Safe Mode Felsäkert läge
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -6808,6 +6808,11 @@ breakText Glyphs Glifler
@msg only an exporter or an importer declares `extensions` `extensions` yalnızca dışa veya içe aktarma eklentisi bildirir
@msg an importer always gets an import's budget: leave `budget` out içe aktarma eklentisi her zaman bir içe aktarmanın bütçesini alır: `budget` değerini çıkarın
# Copy Troubleshooting Info
Copy Troubleshooting Info Sorun Giderme Bilgilerini Kopyala
@msg Copied the troubleshooting info Sorun giderme bilgileri kopyalandı
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report VectorCraft'ın son oturumu kapanmadan önce yanıt vermeyi durdurdu. Yardım › Sorun Giderme Bilgilerini Kopyala, hata raporu için ayrıntıları kopyalar
# Safe Mode
Safe Mode… Güvenli Mod…
Safe Mode Güvenli Mod
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -6461,6 +6461,11 @@ breakText Glyphs Гліфи
@msg only an exporter or an importer declares `extensions` `extensions` оголошує лише модуль експорту або імпорту
@msg an importer always gets an import's budget: leave `budget` out модуль імпорту завжди отримує бюджет імпорту: не вказуйте `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Копіювати відомості для усунення несправностей
@msg Copied the troubleshooting info Відомості для усунення несправностей скопійовано
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Останній сеанс VectorCraft перестав відповідати перед закриттям. Довідка › Копіювати відомості для усунення несправностей копіює подробиці для звіту про помилку
# Safe Mode
Safe Mode… Безпечний режим…
Safe Mode Безпечний режим
Can't render this file because it contains an unexpected character in line 182 and column 5.
+5
View File
@@ -6462,6 +6462,11 @@ breakText Glyphs Glyph
@msg only an exporter or an importer declares `extensions` chỉ plug-in xuất hoặc nhập mới khai báo `extensions`
@msg an importer always gets an import's budget: leave `budget` out plug-in nhập luôn nhận ngân sách của một lần nhập: hãy bỏ `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info Sao chép thông tin khắc phục sự cố
@msg Copied the troubleshooting info Đã sao chép thông tin khắc phục sự cố
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report Phiên VectorCraft trước đã ngừng phản hồi trước khi đóng. Trợ giúp › Sao chép thông tin khắc phục sự cố sẽ sao chép chi tiết cho báo cáo lỗi
# Safe Mode
Safe Mode… Chế độ an toàn…
Safe Mode Chế độ an toàn
Can't render this file because it contains an unexpected character in line 204 and column 5.
+5
View File
@@ -6693,6 +6693,11 @@ breakText Glyphs 字形
@msg only an exporter or an importer declares `extensions` 只有导出或导入增效工具才声明 `extensions`
@msg an importer always gets an import's budget: leave `budget` out 导入增效工具始终获得导入的预算:请省略 `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info 复制故障排除信息
@msg Copied the troubleshooting info 已复制故障排除信息
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 会话在关闭前停止了响应。帮助 › 复制故障排除信息 可复制用于错误报告的详细信息
# Safe Mode
Safe Mode… 安全模式…
Safe Mode 安全模式
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6499,6 +6499,11 @@ breakText Glyphs 字符
@msg only an exporter or an importer declares `extensions` 只有匯出或匯入增效模組才宣告 `extensions`
@msg an importer always gets an import's budget: leave `budget` out 匯入增效模組一律取得匯入的預算:請勿指定 `budget`
# Copy Troubleshooting Info
Copy Troubleshooting Info 複製疑難排解資訊
@msg Copied the troubleshooting info 已複製疑難排解資訊
@msg The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report 上次的 VectorCraft 工作階段在關閉前停止回應。說明 › 複製疑難排解資訊 可複製用於錯誤回報的詳細資料
# Safe Mode
Safe Mode… 安全模式…
Safe Mode 安全模式
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -43,6 +43,7 @@ pub mod font_menu;
mod frame_rate;
mod free_transform;
pub mod graphics;
pub mod hang_beacon;
pub mod i18n;
pub mod icon_data;
pub mod icons;
@@ -80,6 +81,7 @@ pub mod toolbar;
mod tools_float;
pub mod touch;
pub mod trace_runs;
pub mod troubleshooting;
mod ui_fonts;
pub mod unsaved;
pub mod updates_ui;
@@ -228,6 +230,9 @@ pub struct VectorcraftApp {
/// 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>,
/// Where [`Self::run`] marks each command for the desktop app's hang report:
/// [`hang_beacon::BEACON`], or a test's own.
pub(crate) beacon: &'static hang_beacon::Beacon,
/// 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
+1
View File
@@ -674,6 +674,7 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
}
"help.checkForUpdates" => crate::updates_ui::check(app),
"help.safeMode" => crate::dialogs::safe_mode::open(app),
"help.copyTroubleshootingInfo" => crate::troubleshooting::copy(app),
"help.commandPalette" => {
app.ui.palette_open = !app.ui.palette_open;
app.ui.palette_query.clear();
+1
View File
@@ -721,6 +721,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
c("Search Commands…", "help.commandPalette"),
todos("VectorCraft Help…", "F1"),
c("Safe Mode…", "help.safeMode"),
c("Copy Troubleshooting Info", "help.copyTroubleshootingInfo"),
Sep,
c("Check for Updates…", "help.checkForUpdates"),
c("About VectorCraft", "help.about"),
+6
View File
@@ -749,6 +749,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open the Safe Mode dialog (dialog `safeMode`): whether VectorCraft is in Safe Mode, and the disabled plug-ins, each with Enable on Re-launch (engine: plugin.setEnabled). Closing it leaves Safe Mode as it is; Safe Mode is chosen at launch (--safe-mode, or the choice after a launch that stopped while a plug-in was loading, dialog `pluginStopped`)",
),
(
"help.copyTroubleshootingInfo",
"Copy Troubleshooting Info",
"",
"{} put text for a bug report on the clipboard: the version, and in the desktop app the platform, the window system, the graphics processor, the commands and frame times of the last few frames, and the hang reports of this session and the session before (no document contents or file paths) → {chars}",
),
("app.quit", "Quit VectorCraft", "Cmd+Q", "{}"),
(
"ui.swatchOptions",
+4
View File
@@ -179,6 +179,10 @@ pub struct Services {
/// reports for a window on that monitor. Empty on the web, and where the host cannot place a
/// window by these points, which sends floating panels to the main window's monitor.
pub dock_monitors: Vec<craft_ui::docking::Monitor>,
/// The text Help › Copy Troubleshooting Info copies (desktop: the platform, the graphics
/// processor, recent commands and frame times, and the hang reports of this session and the
/// one before). Without it the command copies the version only ([`crate::troubleshooting`]).
pub troubleshooting: Option<Box<dyn Fn() -> String>>,
}
/// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]).
+72
View File
@@ -0,0 +1,72 @@
//! Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`): puts the text the host
//! gives ([`crate::Services::troubleshooting`]) on the clipboard for a bug report. The desktop app
//! gives the platform, the graphics processor, recent commands and frame times, and the hang reports
//! of this session and the one before (its `hang_watch` module); without the service the text is
//! the version only.
use serde_json::{Value, json};
use crate::VectorcraftApp;
/// The status message after the text was put on the clipboard.
pub const COPIED: &str = "Copied the troubleshooting info";
/// The status message at launch when the last session stopped responding and was closed before it
/// answered again.
pub const LAST_SESSION_STOPPED: &str =
"The last session of VectorCraft stopped responding before it closed. Help › Copy Troubleshooting Info copies the details for a bug report";
/// The text the command copies.
pub fn text(app: &VectorcraftApp) -> String {
match &app.services.troubleshooting {
Some(text) => text(),
None => format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION")),
}
}
/// `help.copyTroubleshootingInfo`: put [`text`] on the clipboard. → `{chars}`, the text's length in
/// characters.
pub fn copy(app: &mut VectorcraftApp) -> Result<Value, String> {
let text = text(app);
let chars = text.chars().count();
app.clipboard_out = Some(text);
app.status(COPIED);
Ok(json!({ "chars": chars }))
}
/// Tell the user, at launch, that the last session stopped responding and was closed.
pub fn last_session_stopped(app: &mut VectorcraftApp) {
app.status(LAST_SESSION_STOPPED);
}
#[cfg(test)]
mod tests {
use vectorcraft_engine::Session;
use super::*;
#[test]
fn the_command_copies_the_hosts_text_and_says_so() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.services.troubleshooting = Some(Box::new(|| "platform: test\n".to_string()));
let r = app.run("help.copyTroubleshootingInfo", json!({})).unwrap();
assert_eq!(r["chars"], 15);
assert_eq!(app.clipboard_out.as_deref(), Some("platform: test\n"));
assert_eq!(app.ui.status, COPIED);
}
#[test]
fn without_the_service_the_version_is_copied() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("help.copyTroubleshootingInfo", json!({})).unwrap();
assert_eq!(app.clipboard_out, Some(format!("VectorCraft {}\n", env!("CARGO_PKG_VERSION"))));
}
#[test]
fn running_a_command_marks_it_for_the_hang_report() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.beacon = Box::leak(Box::new(crate::hang_beacon::Beacon::new()));
app.run("help.copyTroubleshootingInfo", json!({})).unwrap();
assert_eq!(app.beacon.recent_commands(), Some(vec!["help.copyTroubleshootingInfo".to_string()]));
}
}
+2
View File
@@ -89,6 +89,8 @@ The desktop app writes its `log` records to standard error and to `logs/vectorcr
By default VectorCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,vectorcraft_render=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`vectorcraft*=debug`). The logger is `apps/vectorcraft/src/logging.rs`. `vectorcraft-cli mcp` has its own logger, which sends records to the MCP client (`docs/mcp.md`).
When the interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-<process id>.txt`, which the process keeps locked. The report gives the part of the frame (the input hook, the app's logic, the interface, or the renderer after a frame in which any window had input), a system clipboard read or write in progress, the last 8 different command ids (a command run again moves to the end of the list), the times of the last 16 frames, the platform, the window system (Wayland, X11, AppKit, Win32, other or unknown), `XDG_CURRENT_DESKTOP`, `XDG_SESSION_TYPE`, whether the app runs in a Flatpak, the graphics adapter, the present mode and frame latency the app asks the window surface for, the session's running time and how many documents are open. It holds no document contents, file paths or command parameters, and no native stack. The time after a frame that minimizes or hides the main window, or starts moving or resizing it, is not timed, and neither is an in-line file dialog (Windows and Linux) or an open menu of the macOS menu bar. When the interface answers again, the log records after how long and the file is removed. A file that no process holds locked at a later launch was left by a session that was closed while it did not answer (a locked one belongs to another instance and is left alone): that launch writes the report to the log, moves the file to `logs/hang-report.1.txt` (older abandoned reports beyond the newest 16 are removed) and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the hang reports of this session and the last one, for a bug report. The watch is `apps/vectorcraft/src/hang_watch.rs`; the marks it reads are `crates/ui-egui/src/hang_beacon.rs`.
## Environment variables (desktop app)
| Variable | Effect |
+1
View File
@@ -1691,6 +1691,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged.
- **Hang report and Copy Troubleshooting Info:** when the desktop app's interface thread stays in one part of a frame for 3 s (VectorCraft's own value), a watching thread writes a hang report to the log and to `logs/hang-report-<process id>.txt`, and removes the file when the interface answers again. A later launch that finds a report no process holds writes it to the log, keeps it as `logs/hang-report.1.txt` and, unless another start-up message is showing, says so in the status bar. Help › Copy Troubleshooting Info (`help.copyTroubleshootingInfo`) copies the platform facts, the recent commands and frame times, and the reports of this session and the last one. Regressions: `apps/vectorcraft/src/hang_watch/tests.rs`, `crates/ui-egui/src/hang_beacon.rs`, `crates/ui-egui/src/troubleshooting.rs`. Scores and hours unchanged.
- **A disabled plug-in whose update stays in the store is turned on:** when `plugin.install` writes a module that lists permissions to the `Plug-ins` folder, its grant can't be written, and the folder can't be returned to the old module (or, on a first install, the new module can't be removed), the folder holds the new module, so a disabled plug-in is turned on, by id and by stored file, as for any install the folder holds. The old grant, or none, doesn't cover the new module, so the next start skips it until it is approved. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
- **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.