feat: web automation wrapper (#1349), DATALINK table on web, System theme; honest decode accounting

Ported from 2c38de53 (#1717). Main already carries the hatch PDF draw
order fix (#1345), the numeric set_properties compare (#1348) and the
web open from bytes, so those parts stay out; what remains:

#1349 — window.ocsControl.ready/request promise wrapper with an
ocs-ready event (web/ocs-control.js, loaded by the web page), and the
startup donation prompt is suppressed once a control request is seen
(opt-in pre-boot via localStorage["opencadstudio.automation"] = "1").
docs/automation/web.md documents both. The control protocol stays at 1.

DATALINK CSV-to-table works on web (picker + bytes through the session
registry — was std::fs, silently dead); COLORSCHEME accepts System
(prefers-color-scheme on web, AppsUseLightTheme on Windows).

Decode accounting, made honest per the fix-of-fix review: into_rgba8
allocates the RGBA destination while the native buffer is alive for
every non-Rgba8 layout (L8 5, La8 6, Rgb8 7, L16 6, La16 8 B/px
peaks — the formula now says so), GIF bills the screen+frame
coexistence at 8 B/px, and TIFF drops the 3x multipliers both ways —
tiff 0.11 splits the given budget between frame and scratch (a total
ceiling), so the old double-billing refused honest 16-bit GeoTIFFs
~2.25x early while admitting hostile chunk transients at 3x.

(cherry picked from commit 2c38de53)
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent f015c3b5fa
commit db8db14006
37 files changed
+436 -52

No files matched your search

+3
View File
@@ -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",
+11 -2
View File
@@ -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
+1
View File
@@ -4423,6 +4423,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = تجميد | T <layer> = إلغاء التجميد | 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
+1
View File
@@ -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
+1
View File
@@ -4408,6 +4408,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Zmrazit | T <layer> = 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
+1
View File
@@ -4409,6 +4409,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <Ebene> = Einfrieren | T <Ebene> = 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
+1
View File
@@ -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
+3
View File
@@ -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 <layer> = Freeze | T <layer> = 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
+1
View File
@@ -4411,6 +4411,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <capa> = Inutilizar | T <capa> = 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
+1
View File
@@ -4410,6 +4410,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Jäädytys | T <layer> = 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
+1
View File
@@ -4411,6 +4411,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Freeze , T <layer> = 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
+1
View File
@@ -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
+1
View File
@@ -4421,6 +4421,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Fagyasztás | T <layer> = 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
+1
View File
@@ -4410,6 +4410,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Congela | T <layer> = 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
+1
View File
@@ -4399,6 +4399,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = フリーズ | T <layer> = フリーズ解除 | 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
+1
View File
@@ -4410,6 +4410,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = 동결 | T <layer> = 해동 | 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
+1
View File
@@ -4409,6 +4409,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <laag> = Bevriezen | T <laag> = 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
+1
View File
@@ -4410,6 +4410,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Zamrożenie | T <layer> = 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
+1
View File
@@ -4411,6 +4411,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <camada> = Congelar | T <camada> = 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
+1
View File
@@ -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
+1
View File
@@ -4371,6 +4371,7 @@ layout =
.vplayer-f-layer-freeze-t-layer-thaw-enter-exit = VPLAYER F <layer> = Dondur | T <layer> = Çö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
+1
View File
@@ -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
+1
View File
@@ -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
+28
View File
@@ -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<u8>)>,
) -> Task<Message> {
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<Message> {
Task::perform(
async {
+59 -40
View File
@@ -1799,51 +1799,25 @@ impl OpenCADStudio {
.push_info(crate::t!("Usage: DATALINK <path-to-.csv>").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(&current_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(&current_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 <CgPoint> survey points. Each element's text content is
+27 -2
View File
@@ -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" {
+84
View File
@@ -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<u16> = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let value: Vec<u16> = "AppsUseLightTheme"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut data: u32 = 0;
let mut size = std::mem::size_of::<u32>() 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<std::path::PathBuf> {
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();
+16
View File
@@ -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<Value>,
/// 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<Message>) {
// 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,
+15
View File
@@ -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<u8>)>),
/// 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<Message>) {
#[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 =
+40 -1
View File
@@ -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();
+8
View File
@@ -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;
+6
View File
@@ -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
+21
View File
@@ -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<u8>)> {
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
+3
View File
@@ -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")),
+18 -7
View File
@@ -772,19 +772,27 @@ fn native_footprint(format: Option<image::ImageFormat>, color: Option<image::Col
image::ColorType::Rgba32F => 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<R: std::io::BufRead + std::io::Seek>(
// 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<R: std::io::BufRead + std::io::Seek>(
})
.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);
+2
View File
@@ -44,6 +44,7 @@
<link data-trunk rel="copy-file" href="web/discussions.json" />
<link data-trunk rel="copy-file" href="web/videos.json" />
<link data-trunk rel="copy-file" href="web/locale-labels.json" />
<link data-trunk rel="copy-file" href="web/ocs-control.js" />
</head>
<body>
<div id="loading">
@@ -51,6 +52,7 @@
<div class="title">Open <span>CAD</span> Studio</div>
<div class="sub">Loading…</div>
</div>
<script src="ocs-control.js" defer></script>
<script>
(() => {
const splash = document.getElementById("loading");
+72
View File
@@ -0,0 +1,72 @@
// Web automation convenience API (#1349).
//
// The wasm module exposes the control bridge through `window.wasmBindings`
// (ocs_control_submit / ocs_control_take). This module wraps it in promises:
//
// await window.ocsControl.ready resolves once the bridge exists
// window.addEventListener("ocs-ready", …) fired once, at the same moment
// await window.ocsControl.request({...}) one control request -> final reply
//
// Hosts that intend to automate from the start should set
// localStorage["opencadstudio.automation"] = "1" before the page loads so the
// startup donation prompt never appears (see docs/automation/web.md).
(() => {
if (window.ocsControl) return;
const bridge = () => {
const api = window.wasmBindings;
return api && api.ocs_control_submit && api.ocs_control_take ? api : null;
};
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
let readyResolve;
const ready = new Promise(resolve => { readyResolve = resolve; });
const settle = () => {
readyResolve(window.ocsControl);
window.dispatchEvent(new Event("ocs-ready"));
};
const poll = setInterval(() => {
if (bridge()) {
clearInterval(poll);
settle();
}
}, 50);
if (bridge()) {
clearInterval(poll);
settle();
}
// Submit one request and poll take() until the app stores its reply.
async function exchange(api, payload, pollMs, deadline) {
const ticket = api.ocs_control_submit(JSON.stringify(payload));
for (;;) {
const raw = api.ocs_control_take(ticket);
if (raw) return JSON.parse(raw);
if (performance.now() >= deadline) {
throw new Error("ocs-control: timed out: " + JSON.stringify(payload));
}
await sleep(pollMs);
}
}
// One control request. Replies that are still `accepted`/`running` (an
// async open, capture or save) are followed through `op:"operation"` until
// a final status arrives. Resolves with the final reply object; rejects
// only on timeout or a missing bridge.
async function request(payload, options = {}) {
const pollMs = options.pollMs ?? 50;
const deadline = performance.now() + (options.timeoutMs ?? 120000);
const api = bridge();
if (!api) {
throw new Error("ocs-control: bridge not ready (await ocsControl.ready)");
}
let reply = await exchange(api, payload, pollMs, deadline);
while (["accepted", "running"].includes(reply.status) && reply.request_id) {
await sleep(pollMs);
reply = await exchange(
api, {op: "operation", request_id: reply.request_id}, pollMs, deadline);
}
return reply;
}
window.ocsControl = {ready, request};
})();