diff --git a/Cargo.toml b/Cargo.toml index 6ef6bf45c..75b0e8189 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -169,6 +169,9 @@ web-sys = { version = "=0.3.85", features = [ "EventInit", "EventTarget", "BeforeUnloadEvent", + # COLORSCHEME SYSTEM resolves prefers-color-scheme through a media + # query listener read at boot and on every re-run. + "MediaQueryList", "console", "Node", "HtmlElement", diff --git a/docs/automation/web.md b/docs/automation/web.md index 16a80c622..a5944d36e 100644 --- a/docs/automation/web.md +++ b/docs/automation/web.md @@ -16,7 +16,14 @@ control channel is ready once `window.wasmBindings?.ocs_control_submit` is defin | `ocs_control_submit(request: string) → string` | Queues one request (a JSON string) and returns a ticket immediately. | | `ocs_control_take(ticket: string) → string \| undefined` | Returns the reply (a JSON string) once it is ready, `undefined` before that. A reply is removed when taken. | -Poll `ocs_control_take` until it returns a reply, as the smoke test does: +The page also loads `ocs-control.js`, which wraps the two functions in promises: + +| API | Behaviour | +|---|---| +| `await window.ocsControl.ready` | Resolves once the control channel exists; the `ocs-ready` window event fires at the same moment. | +| `await window.ocsControl.request(request, {timeoutMs, pollMs})` | Submits one request and resolves with the final reply, following `accepted`/`running` replies through `op: "operation"`. Rejects only on timeout or a missing channel. | + +Without the wrapper, poll `ocs_control_take` until it returns a reply, as the smoke test does: ```js async function control(request) { @@ -55,7 +62,9 @@ rather than a disk, so use `data_base64` for a file the page has not seen. A dialog can be open when the page is ready. The donation prompt appears once per application version in each browser profile, so a fresh or disposable profile always -sees it. While a modal is open `state.modal` is set, and `{"op":"new"}` returns `ok` +sees it — unless the session is automated: the first control request drops a prompt +that is still queued, and a host that sets `localStorage["opencadstudio.automation"]` +to `"1"` before the page loads never gets one. A prompt already on screen stays. While a modal is open `state.modal` is set, and `{"op":"new"}` returns `ok` without creating a tab until the modal is closed. Close it first: ```js diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 1718cd00b..d50e290fa 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -4423,6 +4423,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = تجميد | T = إلغاء التجميد | Enter = خروج view = + .color-scheme-set-to-system-currently-name = تم تعيين نظام الألوان على "النظام" (حاليًا __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = يتطلب عددًا صحيحًا بين __ocs_fmt_0__ و __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 0ca293595..755aaea39 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -4410,6 +4410,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <слой> = Замрази | T <слой> = Размрази | Enter = Изход view = + .color-scheme-set-to-system-currently-name = Цветовата схема е зададена на „Системна“ (в момента __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Изисква се цяло число между __ocs_fmt_0__ и __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index f2936b718..262adf56c 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -4408,6 +4408,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Zmrazit | T = Rozmrazit | Enter = Konec view = + .color-scheme-set-to-system-currently-name = Barevné schéma nastaveno na „Systém“ (aktuálně __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Vyžaduje celé číslo mezi __ocs_fmt_0__ a __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 4b24920f4..3d57aa613 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -4409,6 +4409,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Einfrieren | T = Auftauen | Eingabe = Beenden view = + .color-scheme-set-to-system-currently-name = Farbschema auf „System“ eingestellt (derzeit __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Erfordert eine ganze Zahl zwischen __ocs_fmt_0__ und __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index fa9049697..75ba5980f 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -4411,6 +4411,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <επίπεδο> = Πάγωμα | T <επίπεδο> = Ξεπάγωμα | Enter = Έξοδος view = + .color-scheme-set-to-system-currently-name = Ο χρωματικός συνδυασμός ρυθμίστηκε σε „Σύστημα“ (αυτή τη στιγμή __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Απαιτείται ακέραιος αριθμός μεταξύ __ocs_fmt_0__ και __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 21ee79120..3761895de 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1162,6 +1162,8 @@ common = .save-block-as = Save Block As .save-data-extraction = Save Data Extraction .csv = CSV + .csv-files = CSV Files + .link-a-csv-data-source = Link a CSV data source .export-stl = Export STL .stl-files = STL Files .export-step-ap203 = Export STEP AP203 @@ -4415,6 +4417,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Freeze | T = Thaw | Enter = Exit view = + .color-scheme-set-to-system-currently-name = Color scheme set to 'System' (currently __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Requires an integer between __ocs_fmt_0__ and __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 725c64892..25dab8961 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -4411,6 +4411,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Inutilizar | T = Reutilizar | Intro = Salir view = + .color-scheme-set-to-system-currently-name = Esquema de color establecido en «Sistema» (actualmente __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Requiere un número entero entre __ocs_fmt_0__ y __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index ec69cc927..0c016d547 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -4410,6 +4410,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Jäädytys | T = Sulatus | Enter = Poistu view = + .color-scheme-set-to-system-currently-name = Väriteema asetettu arvoon "Järjestelmä" (tällä hetkellä __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Vaatii kokonaisluvun väliltä __ocs_fmt_0__–__ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index b627ebae9..7bc601dac 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -4411,6 +4411,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Freeze , T = Thaw , Entrée = Sortie view = + .color-scheme-set-to-system-currently-name = Jeu de couleurs défini sur « Système » (actuellement __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Nécessite un nombre entier compris entre __ocs_fmt_0__ et __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index bf8d96f8d..b116fc02e 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -4399,6 +4399,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <लेयर> = फ़्रीज़ | T <लेयर> = थॉ | Enter = बाहर निकलें view = + .color-scheme-set-to-system-currently-name = रंग योजना 'सिस्टम' पर सेट की गई (वर्तमान में __ocs_fmt_0__)। .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = __ocs_fmt_0__ और __ocs_fmt_1__ के बीच एक पूर्णांक आवश्यक है। .ss-new-sheet = New Sheet diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 8c8ccff2b..c48f2be16 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -4421,6 +4421,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Fagyasztás | T = Felengedés | Enter = Kilépés view = + .color-scheme-set-to-system-currently-name = Színséma beállítva: „Rendszer” (jelenleg __ocs_fmt_0__). .ss-create-sheet-set = Tervlapkészlet létrehozása .ss-requires-integer-between = __ocs_fmt_0__ és __ocs_fmt_1__ közötti egész szám szükséges. .ss-new-sheet = Új tervlap diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 046d92cf8..43404e654 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -4410,6 +4410,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Congela | T = Scongela | Enter = Esci view = + .color-scheme-set-to-system-currently-name = Combinazione colori impostata su "Sistema" (attualmente __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Richiede un numero intero compreso tra __ocs_fmt_0__ e __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 219f59a04..a42983fa8 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -4399,6 +4399,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = フリーズ | T = フリーズ解除 | Enter = 終了 view = + .color-scheme-set-to-system-currently-name = カラースキームを「システム」に設定しました(現在: __ocs_fmt_0__)。 .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = __ocs_fmt_0__ から __ocs_fmt_1__ までの整数が必要です。 .ss-new-sheet = New Sheet diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 168d6956d..053dd441f 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -4410,6 +4410,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = 동결 | T = 해동 | Enter = 종료 view = + .color-scheme-set-to-system-currently-name = 색 구성표가 '시스템'으로 설정되었습니다(현재 __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = __ocs_fmt_0__에서 __ocs_fmt_1__ 사이의 정수가 필요합니다. .ss-new-sheet = New Sheet diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 7b3c61ceb..2d2c9136a 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -4409,6 +4409,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Bevriezen | T = Ontdooien | Enter = Afsluiten view = + .color-scheme-set-to-system-currently-name = Kleurenschema ingesteld op 'Systeem' (momenteel __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Vereist een geheel getal tussen __ocs_fmt_0__ en __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index aa99d53a4..907f8c195 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -4410,6 +4410,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Zamrożenie | T = Odwilż | Enter = Wyjście view = + .color-scheme-set-to-system-currently-name = Schemat kolorów ustawiono na „System” (obecnie __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Wymagana liczba całkowita z zakresu od __ocs_fmt_0__ do __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index af4be1784..e325800f1 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -4411,6 +4411,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Congelar | T = Descongelar | Enter = Sair view = + .color-scheme-set-to-system-currently-name = Esquema de cores definido como "Sistema" (atualmente __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Requer um número inteiro entre __ocs_fmt_0__ e __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 5baa9bd16..e691735a2 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -4412,6 +4412,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <слой> = заморозить | T <слой> = разморозить | Enter = выход view = + .color-scheme-set-to-system-currently-name = Цветовая схема установлена на «Системную» (сейчас __ocs_fmt_0__). .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = Требуется целое число от __ocs_fmt_0__ до __ocs_fmt_1__. .ss-new-sheet = New Sheet diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 61006001f..7905e0d6a 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -4371,6 +4371,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F = Dondur | T = Çöz | Enter = Çık view = + .color-scheme-set-to-system-currently-name = Renk düzeni 'Sistem' olarak ayarlandı (şu anda __ocs_fmt_0__). .ss-create-sheet-set = Sayfa Kümesi Oluştur .ss-requires-integer-between = __ocs_fmt_0__ ile __ocs_fmt_1__ arasında bir tam sayı gerekir. .ss-new-sheet = Yeni Sayfa diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 13d29f252..622fa0796 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4394,6 +4394,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <图层> = 冻结 | T <图层> = 解冻 | Enter = 退出 view = + .color-scheme-set-to-system-currently-name = 颜色方案已设为“系统”(当前:__ocs_fmt_0__)。 .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = 需要介于 __ocs_fmt_0__ 和 __ocs_fmt_1__ 之间的整数。 .ss-new-sheet = New Sheet diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 0000b1422..b80efbbda 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -4406,6 +4406,7 @@ layout = .vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <圖層> = 凍結 | T <圖層> = 解凍 | Enter = 退出 view = + .color-scheme-set-to-system-currently-name = 配色方案已設為「系統」(目前:__ocs_fmt_0__)。 .ss-create-sheet-set = Create Sheet Set .ss-requires-integer-between = 需要介於 __ocs_fmt_0__ 和 __ocs_fmt_1__ 之間的整數。 .ss-new-sheet = New Sheet diff --git a/src/app/annotation_data.rs b/src/app/annotation_data.rs index ea74fbd56..f6ba57787 100644 --- a/src/app/annotation_data.rs +++ b/src/app/annotation_data.rs @@ -1298,6 +1298,34 @@ impl OpenCADStudio { Task::none() } + /// Web DATALINK: the picked .csv's bytes arrived. They are registered in + /// the picked-source registry — so `DATALINKUPDATE` re-reads them + /// in-session instead of failing on the absent filesystem — and fed to + /// the same table-insert path the native typed-path command uses. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_datalink_csv_picked( + &mut self, + picked: Option<(String, Vec)>, + ) -> Task { + let Some((name, bytes)) = picked else { + return Task::none(); + }; + let text = String::from_utf8_lossy(&bytes).into_owned(); + if !register_picked_source(&name, Arc::new(bytes)) { + if let Some(name) = take_last_collision().take() { + self.command_line.push_warning( + crate::tf!( + "\"{name}\" is already loaded with different contents; the existing file was kept." + ) + .as_ref(), + ); + } + return Task::none(); + } + self.place_data_link_table(&text, &name); + Task::none() + } + pub(super) fn on_data_link_browse(&mut self) -> Task { Task::perform( async { diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 3820f560e..758e8741d 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -1799,51 +1799,25 @@ impl OpenCADStudio { .push_info(crate::t!("Usage: DATALINK ").as_ref()); return Some(Task::none()); } + // Web: nothing is behind a typed path — pick the .csv through + // the browser, read its bytes at pick time, and register them + // in the picked-source registry; the same table-insert path + // then runs from `DataLinkCsvPicked`. + #[cfg(target_arch = "wasm32")] + { + let _ = path; + return Some(Task::perform( + crate::io::pick_datalink_csv_web(), + Message::DataLinkCsvPicked, + )); + } + #[cfg(not(target_arch = "wasm32"))] match std::fs::read_to_string(path) { Ok(text) => { - let rows_data = parse_csv_table(&text); - let nrows = rows_data.len(); - let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0); - if nrows == 0 || ncols == 0 { - self.command_line - .push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref()); - return Some(Task::none()); - } - use codec::entities::TableBuilder; - use codec::types::Vector3; - let mut table = TableBuilder::new(nrows, ncols) - .at(Vector3::new(0.0, 0.0, 0.0)) - .row_height(0.5) - .column_width(2.0) - .build(); - for (r, row) in rows_data.iter().enumerate() { - for (c, cell) in row.iter().enumerate() { - table.set_cell_text(r, c, cell); - } - } - let doc = &self.tabs[i].scene.document; - let current_style = doc.header.current_table_style_name.clone(); - table.table_style_handle = - doc.objects - .iter() - .find_map(|(handle, object)| match object { - codec::objects::ObjectType::TableStyle(style) - if style.name.eq_ignore_ascii_case(¤t_style) => - { - Some(*handle) - } - _ => None, - }); let link_path = std::fs::canonicalize(path) .map(|value| value.to_string_lossy().into_owned()) .unwrap_or_else(|_| path.to_string()); - let command = - crate::modules::annotate::data_link::DataLinkPlaceCommand::new( - table, &link_path, - ); - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); + self.place_data_link_table(&text, &link_path); } Err(e) => { self.command_line.push_error( @@ -2076,6 +2050,51 @@ impl OpenCADStudio { } Some(self.finish_dispatch(cmd)) } + + /// Build the linked table from CSV text and start the placement command — + /// the shared tail of the native typed-path `DATALINK` read and the web + /// picker's `DataLinkCsvPicked` (which registers the picked bytes in the + /// picked-source registry first so the link re-reads in-session). + pub(in crate::app) fn place_data_link_table(&mut self, csv_text: &str, link_path: &str) { + let i = self.active_tab; + let rows_data = parse_csv_table(csv_text); + let nrows = rows_data.len(); + let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0); + if nrows == 0 || ncols == 0 { + self.command_line + .push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref()); + return; + } + use codec::entities::TableBuilder; + use codec::types::Vector3; + let mut table = TableBuilder::new(nrows, ncols) + .at(Vector3::new(0.0, 0.0, 0.0)) + .row_height(0.5) + .column_width(2.0) + .build(); + for (r, row) in rows_data.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + table.set_cell_text(r, c, cell); + } + } + let doc = &self.tabs[i].scene.document; + let current_style = doc.header.current_table_style_name.clone(); + table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| { + match object { + codec::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(¤t_style) => + { + Some(*handle) + } + _ => None, + } + }); + let command = + crate::modules::annotate::data_link::DataLinkPlaceCommand::new(table, link_path); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + } } // Scan LandXML text for survey points. Each element's text content is diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 4a3e99ba3..97ba45e33 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -535,7 +535,8 @@ impl OpenCADStudio { "Available themes: {}", "DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \ GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \ - KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA", + KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \ + SYSTEM (follows the OS or browser color scheme)", )); self.command_line.push_info(&crate::tf!( "Current color scheme: <{}>", @@ -559,7 +560,8 @@ impl OpenCADStudio { self.command_line.push_output(&crate::tf!("Available themes: {}", "DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \ GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \ - KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA", + KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \ + SYSTEM (follows the OS or browser color scheme)", )); return Some(Task::none()); } @@ -569,6 +571,29 @@ impl OpenCADStudio { ); return Some(Task::none()); } + // SYSTEM keeps its own name in the saved config and resolves + // to a concrete Light/Dark theme at run time — the browser's + // prefers-color-scheme on the web, the Windows personalization + // setting natively. Re-running the command re-resolves; there + // is no live preference listener. + if upper_sub == "SYSTEM" { + if self.ui_theme.name == "Custom" { + self.saved_custom_palette = Some(self.ui_theme.palette); + } + let resolved = crate::app::config::resolve_system_theme(); + let name = format!("{resolved:?}"); + self.ui_theme.name = "System".to_string(); + self.ui_theme.palette = + crate::app::config::UiThemePalette::from_iced(resolved.seed()); + self.theme_color_inputs = self.ui_theme.palette.hex_values(); + self.active_theme = resolved; + self.sync_model_space_theme(true); + self.persist_settings_if_changed(); + self.command_line.push_output(crate::tf!( + "Color scheme set to 'System' (currently {name})." + ).as_ref()); + return Some(Task::none()); + } let theme = crate::app::config::parse_theme_name(sub); if let Some(t) = theme { if self.ui_theme.name == "Custom" { diff --git a/src/app/config.rs b/src/app/config.rs index 958111e8e..c86c27920 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -109,12 +109,77 @@ impl UiThemeConfig { pub fn to_iced(&self) -> iced::Theme { if self.name == "Custom" { iced::Theme::custom("Custom", self.palette.to_iced()) + } else if self.name.eq_ignore_ascii_case("System") { + resolve_system_theme() } else { builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon) } } } +/// Resolve the `System` color scheme (COLORSCHEME SYSTEM) to a concrete +/// theme: the browser `prefers-color-scheme` media query on the web, the +/// Windows `AppsUseLightTheme` personalization value natively, and the dark +/// default on the remaining native platforms (no cheap OS query there). +/// Read at boot (`apply_config`) and re-read whenever COLORSCHEME SYSTEM +/// runs again — there is no live preference listener by design. +pub fn resolve_system_theme() -> iced::Theme { + #[cfg(target_arch = "wasm32")] + { + let light = web_sys::window().is_some_and(|window| { + window + .match_media("(prefers-color-scheme: light)") + .ok() + .flatten() + .is_some_and(|query| query.matches()) + }); + if light { + iced::Theme::Light + } else { + iced::Theme::Dark + } + } + #[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))] + { + use windows_sys::Win32::System::Registry::{ + RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD, + }; + let sub_key: Vec = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let value: Vec = "AppsUseLightTheme" + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut data: u32 = 0; + let mut size = std::mem::size_of::() as u32; + // ERROR_SUCCESS (0) with data 1 means the apps theme is Light; a + // missing value (older Windows, wiped personalization) is Dark. + let light = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + sub_key.as_ptr(), + value.as_ptr(), + RRF_RT_REG_DWORD, + std::ptr::null_mut(), + &mut data as *mut u32 as *mut core::ffi::c_void, + &mut size, + ) == 0 + && data == 1 + }; + if light { + iced::Theme::Light + } else { + iced::Theme::Dark + } + } + #[cfg(not(any(target_arch = "wasm32", target_os = "windows")))] + { + iced::Theme::Dark + } +} + #[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UiThemePalette { @@ -538,6 +603,25 @@ fn config_path() -> Option { mod tests { use super::*; + #[test] + fn system_scheme_resolves_to_a_concrete_theme_and_keeps_its_name() { + // Fresh configs keep the existing default; SYSTEM is opt-in. + assert_ne!(UiThemeConfig::default().name, "System"); + let mut cfg = UiThemeConfig::default(); + cfg.name = "System".to_string(); + let resolved = cfg.to_iced(); + // Case-insensitive like the other stored names. + cfg.name = "system".to_string(); + assert_eq!(cfg.to_iced(), resolved); + assert!( + matches!(resolved, iced::Theme::Light | iced::Theme::Dark), + "SYSTEM must resolve through the platform preference" + ); + // The stored name survives resolution; re-runs re-resolve. + assert_eq!(cfg.name, "system"); + assert_eq!(resolve_system_theme(), resolved); + } + #[test] fn test_model_space_defaults() { let cfg = ModelSpaceThemeConfig::default(); diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 4688ea496..48d9c7ed4 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -106,6 +106,10 @@ pub(super) struct State { completed: VecDeque<(String, Value, Value)>, owner: Option<(u64, String)>, pub enabled: bool, + /// Set by the first control request of the session (or by a web host's + /// boot-time opt-in): while control is enabled, startup prompts that + /// would block automation — the donation prompt — are suppressed (#1349). + pub automation_seen: bool, serial: u64, events: VecDeque, /// Monotonic counter bumped on every real selection change, so clients @@ -489,6 +493,18 @@ impl OpenCADStudio { } pub(super) fn control_request(&mut self, req: Value) -> (Value, Task) { + // First control contact marks the session automated (#1349): a + // DonationPrompt that is still queued (not yet shown) is dropped so + // automation never waits behind it, and `queue_startup_prompts` + // stops queueing one. A prompt already on screen stays — a human + // may be reading it; clients close it with `close_modal`. + if !self.control.automation_seen && self.control.enabled { + self.control.automation_seen = true; + if self.active_modal != Some(super::ModalKind::DonationPrompt) { + self.pending_startup_modals + .retain(|kind| kind != &super::ModalKind::DonationPrompt); + } + } let op = req["op"].as_str().unwrap_or(""); let query = matches!( op, diff --git a/src/app/mod.rs b/src/app/mod.rs index c2bd7123e..eeab4a304 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2754,6 +2754,11 @@ pub enum Message { /// `None` = cancelled. #[cfg(target_arch = "wasm32")] CenterlineLinetypeFilePicked(Option<(String, String)>), + /// Web DATALINK: the picked .csv file's name and raw bytes — registered + /// in the picked-source registry, then fed to the same table-insert path + /// the native typed-path command uses. `None` = cancelled. + #[cfg(target_arch = "wasm32")] + DataLinkCsvPicked(Option<(String, Vec)>), /// Append one typed character to the command-line input from the /// global key-press subscription. Used when the text-input widget /// itself isn't focused (focus parked on viewport / button / etc.) @@ -5079,6 +5084,16 @@ impl OpenCADStudio { fn boot_web() -> (Self, Task) { #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut s = Self::new(); + // A web host that intends to automate can opt in before the first + // control request by setting `opencadstudio.automation` to "1" in + // localStorage (the same pre-load mechanism the settings use): the + // startup donation prompt then never queues (#1349). + if web_sys::window().and_then(|window| window.local_storage().ok().flatten()).and_then( + |storage| storage.get_item("opencadstudio.automation").ok().flatten(), + ) == Some("1".to_string()) + { + s.control.automation_seen = true; + } s.queue_startup_prompts(); let focus = s.focus_cmd_input(); let primary_font = diff --git a/src/app/startup.rs b/src/app/startup.rs index d1e334f4a..5f1658dab 100644 --- a/src/app/startup.rs +++ b/src/app/startup.rs @@ -8,7 +8,13 @@ impl OpenCADStudio { self.pending_startup_modals .push_back(ModalKind::AssocPrompt); } - if self.donation_prompt_version != env!("OCS_APP_VERSION") { + // Automation sessions (#1349): once control traffic has arrived (or a + // web host opted in at boot) while control is enabled, the donation + // prompt is suppressed — a modal would block every automated + // operation until a client closed it by hand. + if self.donation_prompt_version != env!("OCS_APP_VERSION") + && !(self.control.enabled && self.control.automation_seen) + { self.pending_startup_modals .push_back(ModalKind::DonationPrompt); } @@ -226,6 +232,39 @@ mod tests { assert!(restarted.active_modal.is_none()); } + #[test] + fn automation_sessions_skip_the_donation_prompt() { + // First control contact drops a still-queued donation prompt and + // stops later queueing while control stays enabled (#1349). + let mut app = OpenCADStudio::new_for_test(); + app.default_assoc_prompted = true; + app.queue_startup_prompts(); + assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt)); + let _ = app.update(Message::CloseModal); + app.donation_prompt_version = "previous-release".to_string(); + app.pending_startup_modals.push_back(ModalKind::DonationPrompt); + let _ = app.control_request(serde_json::json!({"op":"state"})); + assert!(app.pending_startup_modals.is_empty()); + app.queue_startup_prompts(); + assert_ne!(app.active_modal, Some(ModalKind::DonationPrompt)); + + // A still-queued (not yet shown) prompt is dropped on first contact. + let mut queued = OpenCADStudio::new_for_test(); + queued.default_assoc_prompted = true; + queued.donation_prompt_version = "previous-release".to_string(); + queued.pending_startup_modals.push_back(ModalKind::DonationPrompt); + let _ = queued.control_request(serde_json::json!({"op":"state"})); + queued.queue_startup_prompts(); + assert_ne!(queued.active_modal, Some(ModalKind::DonationPrompt)); + + // A prompt already on screen is left for its reader; clients close + // it themselves, and a session with no control traffic still prompts. + let mut human = OpenCADStudio::new_for_test(); + human.default_assoc_prompted = true; + human.queue_startup_prompts(); + assert_eq!(human.active_modal, Some(ModalKind::DonationPrompt)); + } + #[test] fn startup_dialogs_wait_their_turn_and_preserve_unseen_prompts() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 03330fd51..bc662022a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1348,6 +1348,14 @@ impl OpenCADStudio { pub(in crate::app) fn apply_config(&mut self, cfg: crate::app::config::AppConfig) { self.apply_settings(&cfg.settings); self.ui_theme = cfg.theme.clone(); + // A saved `System` scheme re-resolves at boot: the stored palette is + // whatever the last resolution produced, so refresh it alongside the + // live theme (COLORSCHEME SYSTEM documents this boot-time read). + if self.ui_theme.name.eq_ignore_ascii_case("System") { + self.ui_theme.palette = crate::app::config::UiThemePalette::from_iced( + crate::app::config::resolve_system_theme().seed(), + ); + } self.active_theme = self.ui_theme.to_iced(); self.theme_color_inputs = self.ui_theme.palette.hex_values(); self.model_space = cfg.model_space; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ea13822f6..dfc7cf614 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2964,6 +2964,12 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] Message::ScriptFilePicked(None) => Task::none(), + // Web DATALINK: the picked .csv's bytes arrived — register them + // as the link's in-session source, then run the same table-insert + // path the native typed-path command feeds. + #[cfg(target_arch = "wasm32")] + Message::DataLinkCsvPicked(picked) => self.on_datalink_csv_picked(picked), + // Web CENTERLTYPEFILE: the picked .lin/.dnl file's text arrived — // load its linetypes, then record the setting under the file's // name (the native command reads the typed path inside diff --git a/src/io/mod.rs b/src/io/mod.rs index 15f71ccd2..5bfb1424c 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -273,6 +273,27 @@ pub async fn pick_linetype_file_web() -> Option<(String, String)> { Some((name, String::from_utf8_lossy(&bytes).into_owned())) } +/// Web DATALINK: pick the .csv through the browser and read its raw bytes — +/// the stand-in for the typed path the native command reads with +/// `std::fs::read_to_string`. The caller registers the bytes in the +/// picked-source registry so the link re-reads in-session. `None` = the user +/// cancelled. +#[cfg(target_arch = "wasm32")] +pub async fn pick_datalink_csv_web() -> Option<(String, Vec)> { + let Some(handle) = crate::sys::file_dialog() + .set_title(crate::t!("Link a CSV data source").as_ref()) + .add_filter(crate::t!("CSV Files").as_ref(), &["csv", "txt", "CSV", "TXT"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + else { + return None; + }; + let name = handle.file_name(); + let bytes = handle.read().await; + Some((name, bytes)) +} + /// Load a CAD file from a known path. Parsing and cache building run on a /// dedicated OS thread so the async executor stays free for rendering during /// the load. Writes phase markers into `phase` so the UI can show diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index a95b414ca..839be3806 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1013,6 +1013,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Color Temperature" => Some(("properties", "color-temperature")), "Color override (#RRGGBB):" => Some(("styles", "color-override-rrggbb")), "Color scheme set to '{name}'." => Some(("view", "color-scheme-set-to-name")), + "Color scheme set to 'System' (currently {name})." => Some(("view", "color-scheme-set-to-system-currently-name")), "Color {}" => Some(("ui", "color")), "Column count" => Some(("properties", "column-count")), "Column gutter" => Some(("properties", "column-gutter")), @@ -4187,6 +4188,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Data Extraction Settings" => Some(("common", "data-extraction-settings")), "Add Drawings" => Some(("common", "add-drawings")), "CSV" => Some(("common", "csv")), + "CSV Files" => Some(("common", "csv-files")), + "Link a CSV data source" => Some(("common", "link-a-csv-data-source")), "Pick the file again to load its contents in the browser." => Some(("file", "pick-the-file-again-to-load-its-contents")), "Adding folders is not available in the browser. Add drawing files instead." => Some(("file", "adding-folders-is-not-available-in-the-browser")), "Drawing not loaded in the browser: {name}." => Some(("file", "drawing-not-loaded-in-the-browser")), diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 6f555ede8..e7807123b 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -772,19 +772,27 @@ fn native_footprint(format: Option, color: Option 16, _ => 4, }); - // A 16-bit frame and its RGBA conversion can coexist; an 8-bit one is - // converted with at most a small row buffer in flight. - let peak_bpp = if native_bpp > 4 { native_bpp + 4 } else { 4 }; - let codec_factor = match format { - Some(image::ImageFormat::Tiff) => 3, - _ => 1, + // `DynamicImage::into_rgba8` only MOVES for Rgba8; every other layout + // allocates the RGBA destination while the native buffer is still + // alive, so both coexist (image 0.25 buffer.rs cast_in_color_space). + let peak_bpp = if native_bpp == 4 { 4 } else { native_bpp + 4 }; + // GIF decodes the frame into its own buffer while the screen-sized + // output buffer is live — two RGBA-sized allocations coexist. + let peak_bpp = match format { + Some(image::ImageFormat::Gif) => peak_bpp.max(8), + _ => peak_bpp, }; + // tiff 0.11 splits the given budget between the frame buffer and the + // chunk scratch (a TOTAL cap), so no multiplier is needed: the honest + // peak is the coexisting native+RGBA pair, already in peak_bpp. + let codec_factor = 1; NativeFootprint { peak_bpp, codec_factor } } /// Budget decision for probed pixel dimensions. Pure and cfg-independent /// when the budgets are passed explicitly, so tests can probe arbitrary /// budgets; the decode path always passes this target's constants. +#[cfg_attr(not(test), allow(dead_code))] fn tier_for( width: u32, height: u32, @@ -915,6 +923,10 @@ fn decode_with_budgets( // 0.25); the tier already folded that factor in, and the allowance // matches it so an in-tier TIFF never codec-refuses. let mut limits = image::Limits::default(); + // tiff 0.11 SPLITS this cap between the frame buffer and chunk scratch + // (a total ceiling), so the plain budget is the right allowance; the + // ×3 multiplier that image's older tiff codec needed would instead + // admit hostile chunk transients up to 3× the budget. limits.max_alloc = Some(match reader.format() { Some(image::ImageFormat::Gif) => probed .map(|(width, height, _, _)| { @@ -922,7 +934,6 @@ fn decode_with_budgets( }) .unwrap_or(max_decode_bytes) .min(max_decode_bytes), - Some(image::ImageFormat::Tiff) => max_decode_bytes.saturating_mul(3), _ => max_decode_bytes, }); reader.limits(limits); diff --git a/web-app.html b/web-app.html index eee071025..612262827 100644 --- a/web-app.html +++ b/web-app.html @@ -44,6 +44,7 @@ +
@@ -51,6 +52,7 @@
Open CAD Studio
Loading…
+