diff --git a/src/app/commands/field.rs b/src/app/commands/field.rs index 6d1cf2499..179870ab0 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -4,8 +4,8 @@ use crate::app::{Message, OpenCADStudio}; use crate::command::CadCommand; use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand, FieldTablePickCommand}; -use crate::ui::window::field_dialog::{nav_props, - fields_of, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, DATE_FORMATS, +use crate::ui::window::field_dialog::{ + fields_of, nav_props, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, DATE_FORMATS, NAMED_TYPES, }; use iced::Task; @@ -209,7 +209,11 @@ impl OpenCADStudio { state.ss_prop.clear(); } FieldDialogMsg::SsNode(id) => { - state.ss_prop = state.ss_sets.get(state.ss_set).and_then(|db| nav_props(db, &id).first().map(|p| p.0.clone())).unwrap_or_default(); + state.ss_prop = state + .ss_sets + .get(state.ss_set) + .and_then(|db| nav_props(db, &id).first().map(|p| p.0.clone())) + .unwrap_or_default(); state.ss_node = id; } FieldDialogMsg::SsProp(p) => state.ss_prop = p, @@ -228,10 +232,14 @@ impl OpenCADStudio { ); } FieldDialogMsg::SsPicked(None) => {} - FieldDialogMsg::SsPicked(Some(path)) => match codec::sheet_set::SheetSetDatabase::read(&path.to_string_lossy()) { + FieldDialogMsg::SsPicked(Some(path)) => match crate::app::commands::sheet_set::read_set(&path.to_string_lossy()) { Ok(db) => { let key = codec::sheet_set::path_key(&path.to_string_lossy()); - state.ss_set = match state.ss_sets.iter().position(|d| d.path.as_deref().is_some_and(|p| codec::sheet_set::path_key(p) == key)) { + let open = state + .ss_sets + .iter() + .position(|d| d.path.as_deref().is_some_and(|p| codec::sheet_set::path_key(p) == key)); + state.ss_set = match open { Some(k) => k, None => { state.ss_sets.push(db); diff --git a/src/app/commands/sheet_set.rs b/src/app/commands/sheet_set.rs index 6ef6dcc97..f7a32ac1d 100644 --- a/src/app/commands/sheet_set.rs +++ b/src/app/commands/sheet_set.rs @@ -13,7 +13,6 @@ use crate::scene::model::wire_model::WireModel; use glam::DVec3; use codec::sheet_set::{self as ss, ComponentKind, LayoutReference, SheetSetData, SheetSetDatabase}; use iced::Task; -use std::collections::HashSet; use std::path::{Path, PathBuf}; /// The variables this family answers for. @@ -38,8 +37,8 @@ fn paper_layouts(doc: &codec::CadDocument) -> Vec<(String, String)> { /// UTC now as the reference stamps `UpdateTime`: `yyyy/MM/dd HH:mm:ss.fff`. fn utc_stamp() -> String { - let ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let ms = iced::time::SystemTime::now() + .duration_since(iced::time::SystemTime::UNIX_EPOCH) .map(|d| d.as_millis() as i64) .unwrap_or(0); let (days, rem) = (ms.div_euclid(86_400_000), ms.rem_euclid(86_400_000)); @@ -66,55 +65,116 @@ fn folder_of(path: &str) -> String { Path::new(path).parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default() } -/// Template of a component for new sheets: its own `DefDwtLayout`, else -/// the nearest parent's. -/// The lock files' date: the long date of the user's locale, two spaces and -/// the 24-hour time (`5 Ekim 2026 Pazartesi 09:26:39`). -#[cfg(windows)] -fn lock_stamp() -> String { - use windows_sys::Win32::Globalization::{GetDateFormatEx, GetTimeFormatEx, DATE_LONGDATE}; - let wide = |s: &str| s.encode_utf16().chain(std::iter::once(0)).collect::>(); - let mut date = [0u16; 128]; - let mut time = [0u16; 64]; - let format = wide("HH:mm:ss"); - // SAFETY: the buffers outlive the calls and their lengths are passed. - let (d, t) = unsafe { - ( - GetDateFormatEx(std::ptr::null(), DATE_LONGDATE, std::ptr::null(), std::ptr::null(), date.as_mut_ptr(), date.len() as i32, std::ptr::null()), - GetTimeFormatEx(std::ptr::null(), 0, std::ptr::null(), format.as_ptr(), time.as_mut_ptr(), time.len() as i32), - ) - }; - let text = |b: &[u16], n: i32| String::from_utf16_lossy(&b[..(n.max(1) as usize - 1)]); - format!("{} {}", text(&date, d), text(&time, t)) +/// `.dst` access: native only (a browser has no file paths to read or write). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn read_set(path: &str) -> Result { + SheetSetDatabase::read(path) } -#[cfg(not(windows))] -fn lock_stamp() -> String { - String::new() +#[cfg(target_arch = "wasm32")] +pub(crate) fn read_set(_path: &str) -> Result { + Err(crate::t!("Not available").into_owned()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_set(db: &mut SheetSetDatabase, path: &str) -> Result<(), String> { + db.write(path) +} + +#[cfg(target_arch = "wasm32")] +fn write_set(_db: &mut SheetSetDatabase, _path: &str) -> Result<(), String> { + Err(crate::t!("Not available").into_owned()) +} + +/// The user and machine names a lock file carries. +#[cfg(not(target_arch = "wasm32"))] +fn lock_owner() -> (String, String) { + let user = std::env::var("USERNAME").or_else(|_| std::env::var("USER")).unwrap_or_default(); + let machine = std::env::var("COMPUTERNAME") + .or_else(|_| std::env::var("HOSTNAME")) + .ok() + .or_else(|| std::fs::read_to_string("/etc/hostname").ok().map(|h| h.trim().to_string())) + .unwrap_or_default(); + (user, machine) +} + +/// The drawings whose lock files this application wrote, with the writing +/// process id, kept in the config folder so a crashed session's locks can +/// be told from someone else's and taken back. +#[cfg(not(target_arch = "wasm32"))] +fn lock_record() -> Option { + Some(crate::config::config_dir()?.join("sheet_set_locks.txt")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn recorded_locks() -> Vec<(u32, PathBuf)> { + let text = lock_record().and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default(); + text.lines() + .filter_map(|l| { + let (pid, path) = l.split_once('\t')?; + Some((pid.parse().ok()?, PathBuf::from(path))) + }) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +fn record_lock(path: &Path, held: bool) { + let Some(record) = lock_record() else { return }; + let mut locks = recorded_locks(); + locks.retain(|(_, p)| p != path); + if held { + locks.push((std::process::id(), path.to_path_buf())); + } + let text: String = locks.iter().map(|(pid, p)| format!("{pid}\t{}\n", p.display())).collect(); + if let Some(dir) = record.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(record, text); +} + +/// Whether the `.dwl` next to `path` is one an earlier session of this +/// application left behind (it crashed): recorded by another process, and +/// still naming this user and machine. +// ponytail: the recorded process is assumed gone (the app is single +// instance); check it is alive if several instances ever share a profile. +#[cfg(not(target_arch = "wasm32"))] +fn stale_own_lock(path: &Path) -> bool { + let me = std::process::id(); + if !recorded_locks().iter().any(|(pid, p)| p == path && *pid != me) { + return false; + } + let text = std::fs::read_to_string(path.with_extension("dwl")).unwrap_or_default(); + let mut lines = text.lines().map(str::trim); + let (user, machine) = lock_owner(); + lines.next() == Some(user.as_str()) && lines.next() == Some(machine.as_str()) } /// `.dwl` (user, machine, date lines) and `.dwl2` (the same -/// as XML), as the reference writes them for a drawing it has open. +/// as XML), as the reference writes them for a drawing it has open. The date +/// is the OS long date, two spaces and the 24-hour time. // ponytail: .dwl is written as UTF-8; the reference's encoding of non-ASCII // day names was not measured. #[cfg(not(target_arch = "wasm32"))] fn write_lock(path: &Path) -> std::io::Result<()> { - let user = std::env::var("USERNAME").or_else(|_| std::env::var("USER")).unwrap_or_default(); - let machine = std::env::var("COMPUTERNAME").unwrap_or_default(); - let stamp = lock_stamp(); + let (user, machine) = lock_owner(); + let stamp = crate::entities::field::long_date_time_now(); std::fs::write(path.with_extension("dwl"), format!("{user}\n{machine} \n{stamp}"))?; std::fs::write( path.with_extension("dwl2"), format!( - "\n\n{user}\n{machine} \n\n{stamp}\n" + "\n\n{user}\n\ + {machine} \n\n{stamp}\n" ), - ) + )?; + record_lock(path, true); + Ok(()) } #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn release_lock(path: &Path) { let _ = std::fs::remove_file(path.with_extension("dwl")); let _ = std::fs::remove_file(path.with_extension("dwl2")); + record_lock(path, false); } /// The insertion point of Place on Sheet (with the scale, picked from the @@ -306,6 +366,8 @@ fn rename_form(db: &SheetSetDatabase, set: usize, id: &str) -> Form { } } +/// Template of a component for new sheets: its own `DefDwtLayout`, else +/// the nearest parent's. fn template_of(db: &SheetSetDatabase, component: &str) -> Option { let mut id = component.to_string(); for _ in 0..32 { @@ -468,6 +530,7 @@ impl OpenCADStudio { /// Publish the open sets to the field engine and redraw every field. fn sheet_sets_changed(&mut self) { crate::entities::field::set_sheet_sets(self.sheet_set.sets.clone()); + self.sync_drawing_locks(); self.refresh_locations(); if self.sheet_set.settings.sheet_status >= 1 { self.refresh_sheet_status(); @@ -475,7 +538,9 @@ impl OpenCADStudio { self.sheet_set.status.clear(); } for tab in &mut self.tabs { - refresh_sheet_fields(&mut tab.scene); + if refresh_sheet_fields(&mut tab.scene) { + tab.dirty = true; + } } } @@ -490,7 +555,7 @@ impl OpenCADStudio { { self.sheet_set.current = Some(k); } else { - let db = SheetSetDatabase::read(&path.to_string_lossy())?; + let db = read_set(&path.to_string_lossy())?; self.sheet_set.sets.push(db); self.sheet_set.current = Some(self.sheet_set.sets.len() - 1); } @@ -501,19 +566,31 @@ impl OpenCADStudio { Ok(()) } - /// Lock files for the drawings open in tabs, as other instances expect - /// them (the sheet status shows such a sheet as open); a drawing that - /// already has someone else's lock file is left alone. + /// Lock files for the sheets of the open sets that are open in tabs, as + /// other instances expect them (the sheet status shows such a sheet as + /// open). A drawing with someone else's lock file is left alone; a lock + /// a crashed session of this application left is taken back. With no set + /// open every lock is released. #[cfg(not(target_arch = "wasm32"))] fn sync_drawing_locks(&mut self) { - let open: HashSet = self.tabs.iter().filter_map(|t| t.current_path.clone()).filter(|p| p.exists()).collect(); + let sets = &self.sheet_set.sets; + let open: std::collections::HashSet = self + .tabs + .iter() + .filter_map(|t| t.current_path.clone()) + .filter(|p| { + let drawing = p.to_string_lossy(); + sets.iter().any(|db| db.sheet_for(&drawing, None).is_some()) + }) + .filter(|p| p.exists()) + .collect(); let gone: Vec = self.sheet_set.locks.difference(&open).cloned().collect(); for p in gone { release_lock(&p); self.sheet_set.locks.remove(&p); } for p in open { - if self.sheet_set.locks.contains(&p) || p.with_extension("dwl").exists() { + if self.sheet_set.locks.contains(&p) || (p.with_extension("dwl").exists() && !stale_own_lock(&p)) { continue; } if write_lock(&p).is_ok() { @@ -522,9 +599,6 @@ impl OpenCADStudio { } } - #[cfg(target_arch = "wasm32")] - fn sync_drawing_locks(&mut self) {} - /// Reload every open set whose `.dst` was changed by someone else. fn poll_sheet_sets(&mut self) { let mut changed = false; @@ -539,7 +613,7 @@ impl OpenCADStudio { if self.sheet_set.seen.insert(key, modified).is_none_or(|t| t == modified) { continue; } - if let Ok(fresh) = SheetSetDatabase::read(&path) { + if let Ok(fresh) = read_set(&path) { if fresh.root != db.root { *db = fresh; changed = true; @@ -559,7 +633,7 @@ impl OpenCADStudio { let Some(path) = db.path.clone() else { return; }; - if let Err(e) = db.write(&path) { + if let Err(e) = write_set(db, &path) { self.command_line.push_error(&format!("{path}: {e}")); } self.sheet_sets_changed(); @@ -660,7 +734,7 @@ impl OpenCADStudio { self.sync_drawing_locks(); self.poll_sheet_sets(); // SSMSHEETSTATUS 2: the status is taken again every SSMPOLLTIME seconds. - let every = std::time::Duration::from_secs(u64::from(self.sheet_set.settings.poll_time.max(20))); + let every = iced::time::Duration::from_secs(u64::from(self.sheet_set.settings.poll_time.max(20))); if self.sheet_set.settings.sheet_status == 2 && self.sheet_set.status_at.is_none_or(|t| t.elapsed() >= every) { self.refresh_sheet_status(); } @@ -789,6 +863,10 @@ impl OpenCADStudio { self.sheet_set.submenu = None; return self.view_menu(id, action); } + // Browsers expose no folder picker. + #[cfg(target_arch = "wasm32")] + SheetSetMsg::AddLocation | SheetSetMsg::Browse(_) => {} + #[cfg(not(target_arch = "wasm32"))] SheetSetMsg::AddLocation => { return Task::perform( async { @@ -816,7 +894,7 @@ impl OpenCADStudio { SheetSetMsg::OpenDrawing(path) => return self.update(Message::OpenRecent(PathBuf::from(path))), SheetSetMsg::Refresh => { if let Some(path) = self.sheet_set.db().and_then(|db| db.path.clone()) { - match SheetSetDatabase::read(&path) { + match read_set(&path) { Ok(db) => { if let Some(slot) = self.sheet_set.db_mut() { *slot = db; @@ -838,6 +916,7 @@ impl OpenCADStudio { SheetSetMsg::FolderPicked(field, Some(path)) => self.dialog_folder(field, path), SheetSetMsg::FolderPicked(_, None) => {} SheetSetMsg::TemplatePicked(Some(path)) => self.properties_template(path), + #[cfg(not(target_arch = "wasm32"))] SheetSetMsg::Browse(field) => { return Task::perform( async { @@ -1138,8 +1217,8 @@ impl OpenCADStudio { pub(in crate::app) fn sheet_set_after_open(&mut self, i: usize) -> Task { let mut task = Task::none(); // A sheet's fields show its current values once it is open. - if !self.sheet_set.sets.is_empty() { - refresh_sheet_fields(&mut self.tabs[i].scene); + if !self.sheet_set.sets.is_empty() && refresh_sheet_fields(&mut self.tabs[i].scene) { + self.tabs[i].dirty = true; } let path = self.tabs[i].current_path.as_ref().map(|p| p.to_string_lossy().to_string()); if let (Some(path), Some((want, layout))) = (path.as_ref(), self.sheet_set_pending_layout.as_ref()) { @@ -1925,7 +2004,7 @@ impl OpenCADStudio { /// SSMSHEETSTATUS: a sheet whose drawing is missing, or open (its `.dwl` /// lock file, or a tab of this application). pub(in crate::app) fn refresh_sheet_status(&mut self) { - self.sheet_set.status_at = Some(std::time::Instant::now()); + self.sheet_set.status_at = Some(iced::time::Instant::now()); self.sheet_set.status.clear(); if self.sheet_set.settings.sheet_status == 0 { return; @@ -2105,7 +2184,7 @@ impl OpenCADStudio { } } } - if let Err(e) = db.write(&path) { + if let Err(e) = write_set(&mut db, &path) { w.error = Some(e); self.sheet_set.dialog = Some(SsDialog::Wizard(w)); return Task::none(); @@ -2509,16 +2588,15 @@ fn write_properties(db: &mut SheetSetDatabase, p: &Properties) { } /// Store fresh values for a drawing's sheet set fields (the fields keep them, -/// as when the reference updates them) and redraw their hosts. -fn refresh_sheet_fields(scene: &mut crate::scene::Scene) { - crate::entities::field::refresh_sheet_set_fields(&mut scene.document); - let changes: Vec<_> = scene - .document - .entities() - .filter(|e| crate::entities::field::hosts_field(&scene.document, e)) - .map(|e| (e.common().handle, crate::scene::ChangeKind::Modified)) +/// as when the reference updates them) and redraw the hosts that changed; +/// whether any did. +fn refresh_sheet_fields(scene: &mut crate::scene::Scene) -> bool { + let changes: Vec<_> = crate::entities::field::refresh_sheet_set_fields(&mut scene.document) + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); if !changes.is_empty() { scene.bump_entities(&changes); } + !changes.is_empty() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3502c4e3d..1d6ac199b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2643,12 +2643,15 @@ impl OpenCADStudio { }; #[cfg(target_arch = "wasm32")] let plugin_drain = Subscription::none(); - // Open sheet sets reload when their .dst changes on disk. + // While a sheet set is open: it reloads when its .dst changes on disk, + // and its sheets' .dwl lock files follow the open tabs. #[cfg(not(target_arch = "wasm32"))] - // Open sheet sets reload when their .dst changes on disk; the - // drawings' .dwl lock files follow the open tabs. - let sheet_set_poll = iced::time::every(std::time::Duration::from_secs(1)) - .map(|_| Message::SheetSet(crate::ui::window::sheet_set::SheetSetMsg::Poll)); + let sheet_set_poll = if self.sheet_set.sets.is_empty() { + Subscription::none() + } else { + iced::time::every(std::time::Duration::from_secs(1)) + .map(|_| Message::SheetSet(crate::ui::window::sheet_set::SheetSetMsg::Poll)) + }; #[cfg(target_arch = "wasm32")] let sheet_set_poll = Subscription::none(); let hatch_pattern_keys = if self.tabs[self.active_tab] diff --git a/src/entities/field.rs b/src/entities/field.rs index f584e1280..a5c2fd25b 100644 --- a/src/entities/field.rs +++ b/src/entities/field.rs @@ -387,10 +387,69 @@ pub fn attach_attribute_fields(document: &mut CadDocument, insert: Handle) -> Ve document.attach_attribute_fields(insert, &context) } -/// Store fresh values for the sheet set fields (after a sheet set changed). +/// Store fresh values for the sheet set fields (after a sheet set changed) +/// and return the hosts whose text changed. A field no open sheet set +/// resolves (`####`: its set was closed or not found) keeps its stored value. pub fn refresh_sheet_set_fields(document: &mut CadDocument) -> Vec { let context = OcsFieldContext(None); - document.refresh_sheet_set_fields(&context) + let unresolved: Vec<(Handle, String)> = document + .fields + .values() + .filter(|f| f.evaluator.starts_with("AcSm")) + .filter(|f| { + let host = field_host(document, f.handle).unwrap_or(Handle::NULL); + codec::fields::resolve_handle(document, f.handle, host, &context).is_none_or(|v| v == "####") + }) + .map(|f| (f.handle, f.evaluator.clone())) + .collect(); + // ponytail: the engine refreshes every sheet set field, so the unresolved + // ones are hidden from it (evaluator cleared) for this one call; a + // "skip unresolved" switch in the engine would replace this. + for (h, _) in &unresolved { + if let Some(f) = document.fields.get_mut(h) { + f.evaluator.clear(); + } + } + let changed = document.refresh_sheet_set_fields(&context); + for (h, evaluator) in unresolved { + if let Some(f) = document.fields.get_mut(&h) { + f.evaluator = evaluator; + } + } + changed +} + +/// The entity hosting field `field`: up its owner chain (fields, then the +/// field dictionary and extension dictionary) to the first non-object. +fn field_host(document: &CadDocument, field: Handle) -> Option { + let owner = |h: Handle| -> Option { + if let Some(f) = document.fields.get(&h) { + return Some(f.owner); + } + match document.objects.get(&h)? { + codec::objects::ObjectType::Dictionary(d) => Some(d.owner), + codec::objects::ObjectType::Unknown { owner, .. } => Some(*owner), + _ => None, + } + }; + let mut h = field; + for _ in 0..12 { + let o = owner(h)?; + if owner(o).is_none() { + return Some(o); + } + h = o; + } + None +} + +/// Now as the OS long date, two spaces and the 24-hour time +/// (`Monday, October 5, 2026 09:26:39`). +pub fn long_date_time_now() -> String { + let locale = os_date_locale(); + let picture = format!("{} HH:mm:ss", locale.long_date); + let now = now_utc_julian() + utc_offset_days(); + codec::fields::format_dt_in(codec::fields::julian_parts(now), &picture, &locale) } /// [`attach_attribute_fields`] with every field code passed through `map`. diff --git a/src/ui/window/sheet_set.rs b/src/ui/window/sheet_set.rs index d136db048..82a1a5592 100644 --- a/src/ui/window/sheet_set.rs +++ b/src/ui/window/sheet_set.rs @@ -67,7 +67,7 @@ pub struct SheetSetManager { pub seen: std::collections::HashMap, /// Sheet status by sheet id (SSMSHEETSTATUS) and when it was taken. pub status: std::collections::HashMap, - pub status_at: Option, + pub status_at: Option, /// Sheet Views: by category (else by sheet). pub by_category: bool, /// Model Views: location id, folder and its drawings.