chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Rust
|
||||
/target/
|
||||
# .NET
|
||||
**/bin/
|
||||
**/obj/
|
||||
# Electron / Node
|
||||
electron/node_modules/
|
||||
electron/package-lock.json
|
||||
Generated
+312
@@ -0,0 +1,312 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "legacy-app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orchestrator"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
@@ -0,0 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["legacy-app", "orchestrator"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
@@ -0,0 +1,89 @@
|
||||
# Multi-cursor background computer-use demo — "National Records System"
|
||||
|
||||
A fleet of deliberately **legacy-looking government records terminals** (navy
|
||||
banner, `UNCLASSIFIED // FOR OFFICIAL USE ONLY` strip, function-key bar,
|
||||
green-screen records grid, status line) — the kind of internal agency app that,
|
||||
in the age of AI, has *no* automation integration. cua-driver automates them
|
||||
anyway.
|
||||
|
||||
One human action in the foreground "master" terminal is replayed onto **four
|
||||
background terminals at the same time**, each driven by its own cua-driver
|
||||
session = its own uniquely-coloured agent cursor — with **no window ever
|
||||
raised** and **the user's mouse never moved**.
|
||||
|
||||
It also proves cua-driver works **with or without an accessibility tree**: the
|
||||
five windows span five UI frameworks, and cua-driver's default dispatch
|
||||
auto-selects UIA-Invoke where an a11y tree exists and falls back to
|
||||
pixel/pointer-injection where it doesn't.
|
||||
|
||||
## Layout (each window = ½ work-width × ½ work-height)
|
||||
|
||||
The four corners tile the taskbar-safe work area into quadrants; the master is
|
||||
centered, **overlapping all four**:
|
||||
|
||||
```
|
||||
┌────────────────────────┬────────────────────────┐
|
||||
│ Win32 GDI (NO a11y) │ WinForms (.NET) │
|
||||
│ crimson ● │ amber ● │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ MASTER — Win32 controls │ ← you │
|
||||
├───────────│ (foreground, overlaps) │─────────────┤
|
||||
│ WPF (XAML)│ │ Electron │
|
||||
│ └────────────────────────┘ mint_lime ● │
|
||||
│ aqua ● │ (Chromium) │
|
||||
└────────────────────────┴────────────────────────┘
|
||||
```
|
||||
|
||||
Click **SUBMIT** (or type a subject name then submit) in the center master:
|
||||
four coloured cursors glide onto the four corner terminals and commit the same
|
||||
record there — concurrently, in the background. Watch each corner's
|
||||
green-screen records grid grow and its `RECORDS:` counter tick up, without any
|
||||
corner ever coming to the front.
|
||||
|
||||
## Frameworks (and what they exercise)
|
||||
|
||||
| Window | Framework | Accessibility | cua-driver path |
|
||||
|---|---|---|---|
|
||||
| TL | Win32 + GDI (custom-drawn) | **none** | pixel hit-test → PostMessage / pointer injection |
|
||||
| TR | .NET WinForms | MSAA/UIA | UIA Invoke |
|
||||
| BL | .NET WPF | UIA (XAML) | UIA Invoke (no foreground steal via WS_EX_NOACTIVATE) |
|
||||
| BR | Electron | UIA (Chromium) | UIA Invoke |
|
||||
| Center | Win32 standard controls | MSAA | (foreground; the human drives it) |
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
# from this directory
|
||||
cargo build # legacy-app + orchestrator (Rust)
|
||||
dotnet build dotnet/winforms/winforms.csproj # WinForms
|
||||
dotnet build dotnet/wpf/wpf.csproj # WPF
|
||||
npm install --prefix electron # Electron (downloads electron once)
|
||||
```
|
||||
Also build the driver once (repo root workspace):
|
||||
```powershell
|
||||
cargo build -p cua-driver --manifest-path ..\..\libs\cua-driver\rust\Cargo.toml
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
.\target\debug\orchestrator.exe # human-driven: click/type in the center
|
||||
.\target\debug\orchestrator.exe --auto # self-playing: drives a TYPE+CLICK every few seconds
|
||||
```
|
||||
|
||||
The orchestrator starts the cua-driver daemon, launches + positions all five
|
||||
windows, and fans every center action out to the four corners over four
|
||||
concurrent `cua-driver call` sessions (`crimson` / `amber` / `aqua` /
|
||||
`mint_lime` → four cursor colours). Close the center window (or kill the
|
||||
orchestrator) to tear everything down — a Windows Job Object kills the whole
|
||||
tree, so nothing is orphaned.
|
||||
|
||||
### Env overrides
|
||||
`CUA_DRIVER_EXE`, `LEGACY_APP_EXE`, `WINFORMS_EXE`, `WPF_EXE`, `ELECTRON_DIR`.
|
||||
|
||||
## How the coloured cursors work
|
||||
cua-driver assigns each session a cursor colour by name (palette-name sessions
|
||||
like `crimson` pick that colour directly). Passing `"session":"<color>"` on each
|
||||
`click`/`type_text` call routes it to that session's overlay cursor, which
|
||||
glides to the target. Four sessions → four cursors animating at once. See
|
||||
`docs/windows-background-input-re-plan.md` for the no-z-raise mechanism.
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
// "Meridian CRM — Account Record" : WinForms node (classic Win32 controls,
|
||||
// MSAA/UIA). Explicit fractional layout (dense, no gaps beyond borders).
|
||||
// cua-driver drives the Account Name field + the "Add Record" button.
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
|
||||
var f = new Form { Text = "Meridian CRM — Account Record [WinForms]", ClientSize = new Size(960, 600),
|
||||
StartPosition = FormStartPosition.Manual, Font = new Font("Segoe UI", 9f), BackColor = Color.FromArgb(240,240,240) };
|
||||
|
||||
Label band(string t, Color bg, ContentAlignment a = ContentAlignment.MiddleLeft, FontStyle fs = FontStyle.Regular)
|
||||
=> new Label { Text = t, BackColor = bg, TextAlign = a, Font = new Font("Segoe UI", 9f, fs) };
|
||||
|
||||
var menu = band(" File Edit View Record Tools Help", Color.FromArgb(247,247,247));
|
||||
var tool = band(" New Open Save Delete │ ◀ Prev Next ▶ │ Find", Color.FromArgb(235,235,235));
|
||||
var nameLbl = band(" Account Name", Color.FromArgb(240,240,240));
|
||||
var typeLbl = band(" Account Type", Color.FromArgb(240,240,240));
|
||||
var regionLbl = band(" Region", Color.FromArgb(240,240,240));
|
||||
var prioLbl = band(" Priority (1-5)", Color.FromArgb(240,240,240));
|
||||
var creditLbl = band(" Credit Limit", Color.FromArgb(240,240,240));
|
||||
var nameBox = new TextBox { BorderStyle = BorderStyle.FixedSingle };
|
||||
var typeCmb = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, FlatStyle = FlatStyle.Flat };
|
||||
typeCmb.Items.AddRange(new object[] { "Enterprise", "SMB", "Government", "Reseller" }); typeCmb.SelectedIndex = 0;
|
||||
var regionCmb = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, FlatStyle = FlatStyle.Flat };
|
||||
regionCmb.Items.AddRange(new object[] { "North", "South", "EMEA", "APAC", "LATAM" }); regionCmb.SelectedIndex = 0;
|
||||
var prio = new TrackBar { Minimum = 1, Maximum = 5, Value = 3, TickStyle = TickStyle.BottomRight };
|
||||
var credit = new TrackBar { Minimum = 0, Maximum = 100, Value = 40, TickFrequency = 10, TickStyle = TickStyle.BottomRight };
|
||||
var saveBtn = new Button { Text = "Add Record", FlatStyle = FlatStyle.System, Font = new Font("Segoe UI", 10f, FontStyle.Bold) };
|
||||
var sigHdr = band(" Signature / Notes", Color.FromArgb(225,225,225), ContentAlignment.MiddleLeft, FontStyle.Bold);
|
||||
var sig = new Panel { BackColor = Color.White, BorderStyle = BorderStyle.FixedSingle };
|
||||
var grid = new DataGridView { AllowUserToAddRows = false, ReadOnly = true, BackgroundColor = Color.White,
|
||||
BorderStyle = BorderStyle.FixedSingle, RowHeadersVisible = false, AllowUserToResizeRows = false,
|
||||
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing };
|
||||
grid.Columns.Add("acct", "Account"); grid.Columns.Add("type", "Type"); grid.Columns.Add("region", "Region");
|
||||
grid.Columns.Add("pri", "Pri"); grid.Columns.Add("credit", "Credit");
|
||||
grid.Columns["acct"].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
|
||||
var status = band(" Ready", Color.FromArgb(232,232,232));
|
||||
var recLbl = band("Records: 0 USER: SYSTEM ▌ CONNECTED ", Color.FromArgb(232,232,232), ContentAlignment.MiddleRight);
|
||||
|
||||
// line tool: one straight segment per press-drag-release (down→up)
|
||||
var segs = new List<(Point a, Point b)>(); Point? down = null; Point cur = Point.Empty;
|
||||
sig.MouseDown += (s, e) => { down = e.Location; cur = e.Location; };
|
||||
sig.MouseMove += (s, e) => { if (down != null) { cur = e.Location; sig.Invalidate(); } };
|
||||
sig.MouseUp += (s, e) => { if (down != null) { segs.Add((down.Value, e.Location)); down = null; sig.Invalidate(); } };
|
||||
sig.Paint += (s, e) => { e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
using var p = new Pen(Color.MidnightBlue, 2); foreach (var sg in segs) e.Graphics.DrawLine(p, sg.a, sg.b);
|
||||
if (down != null) { using var pp = new Pen(Color.FromArgb(110, 25, 25, 112), 1); e.Graphics.DrawLine(pp, down.Value, cur); } };
|
||||
|
||||
int records = 0;
|
||||
saveBtn.Click += (s, e) =>
|
||||
{
|
||||
string nm = nameBox.Text.Trim(); if (nm.Length == 0) nm = "(unnamed)";
|
||||
grid.Rows.Add(nm, typeCmb.Text, regionCmb.Text, prio.Value, "$" + (credit.Value * 1000));
|
||||
if (grid.Rows.Count > 0) grid.FirstDisplayedScrollingRowIndex = grid.Rows.Count - 1;
|
||||
records++; status.Text = $" Saved account '{nm}'."; recLbl.Text = $"Records: {records} USER: SYSTEM ▌ CONNECTED ";
|
||||
nameBox.Clear();
|
||||
};
|
||||
|
||||
foreach (Control c in new Control[] { menu, tool, nameLbl, typeLbl, regionLbl, prioLbl, creditLbl,
|
||||
nameBox, typeCmb, regionCmb, prio, credit, saveBtn, sigHdr, sig, grid, status, recLbl })
|
||||
f.Controls.Add(c);
|
||||
|
||||
void Layout()
|
||||
{
|
||||
int W = f.ClientSize.Width, H = f.ClientSize.Height;
|
||||
int X(double a) => (int)(W * a); int Y(double a) => (int)(H * a);
|
||||
Rectangle R(double x0, double y0, double x1, double y1) => Rectangle.FromLTRB(X(x0), Y(y0), X(x1), Y(y1));
|
||||
menu.Bounds = R(0, 0, 1, 0.045);
|
||||
tool.Bounds = R(0, 0.045, 1, 0.095);
|
||||
double lc = 0.13; // label/control split
|
||||
nameLbl.Bounds = R(0, 0.105, lc, 0.16); nameBox.Bounds = R(lc, 0.105, 0.42, 0.16);
|
||||
typeLbl.Bounds = R(0, 0.165, lc, 0.22); typeCmb.Bounds = R(lc, 0.167, 0.42, 0.22);
|
||||
regionLbl.Bounds = R(0, 0.225, lc, 0.28); regionCmb.Bounds = R(lc, 0.227, 0.42, 0.28);
|
||||
prioLbl.Bounds = R(0, 0.285, lc, 0.345); prio.Bounds = R(lc, 0.285, 0.42, 0.345);
|
||||
creditLbl.Bounds = R(0, 0.35, lc, 0.41); credit.Bounds = R(lc, 0.35, 0.42, 0.41);
|
||||
saveBtn.Bounds = R(0.04, 0.45, 0.40, 0.52);
|
||||
sigHdr.Bounds = R(0.42, 0.095, 1, 0.135);
|
||||
sig.Bounds = R(0.42, 0.135, 0.995, 0.42);
|
||||
grid.Bounds = R(0.005, 0.55, 0.995, 0.93);
|
||||
status.Bounds = R(0, 0.94, 0.5, 1);
|
||||
recLbl.Bounds = R(0.5, 0.94, 1, 1);
|
||||
float gfs = Math.Max(8, H / 70f);
|
||||
grid.Font = new Font("Segoe UI", gfs);
|
||||
grid.ColumnHeadersDefaultCellStyle.Font = new Font("Segoe UI", gfs, FontStyle.Bold);
|
||||
}
|
||||
f.Load += (s, e) => Layout();
|
||||
f.Resize += (s, e) => Layout();
|
||||
Application.Run(f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AssemblyName>winforms-legacy</AssemblyName>
|
||||
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
|
||||
// "Meridian CRM — Account Record" : WPF node (XAML / UIA). Real Slider /
|
||||
// ComboBox / InkCanvas (doodle) / DataGrid (spreadsheet). Account Name TextBox
|
||||
// + SAVE button are what cua-driver drives.
|
||||
class Program
|
||||
{
|
||||
public class RowVM
|
||||
{
|
||||
public string Account { get; set; } public string Type { get; set; }
|
||||
public string Region { get; set; } public int Pri { get; set; } public string Credit { get; set; }
|
||||
}
|
||||
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
var app = new Application();
|
||||
var rows = new ObservableCollection<RowVM>();
|
||||
var gray = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0));
|
||||
|
||||
// menu
|
||||
var menu = new Menu();
|
||||
foreach (var m in new[] { "File", "Edit", "View", "Record", "Tools", "Help" })
|
||||
menu.Items.Add(new MenuItem { Header = m });
|
||||
// toolbar
|
||||
var tray = new ToolBarTray();
|
||||
var tb = new ToolBar();
|
||||
foreach (var t in new[] { "New", "Open", "Save", "Delete", "|", "◀ Prev", "Next ▶", "|", "Find" })
|
||||
tb.Items.Add(t == "|" ? (object)new Separator() : new Button { Content = t, Padding = new Thickness(6, 1, 6, 1) });
|
||||
tray.ToolBars.Add(tb);
|
||||
|
||||
// ── left form (dense grid; cell borders only) ───────────────────────
|
||||
var form = new Grid { Background = Brushes.White };
|
||||
form.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
|
||||
form.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
TextBlock lbl(string t) => new TextBlock { Text = " " + t, VerticalAlignment = VerticalAlignment.Center, Background = new SolidColorBrush(Color.FromRgb(0xF0, 0xF0, 0xF0)) };
|
||||
int r = 0;
|
||||
void AddRow(string label, FrameworkElement c, double h = 30)
|
||||
{
|
||||
form.RowDefinitions.Add(new RowDefinition { Height = new GridLength(h) });
|
||||
var b1 = new Border { BorderBrush = gray, BorderThickness = new Thickness(0, 0, 1, 1), Child = lbl(label) };
|
||||
Grid.SetRow(b1, r); Grid.SetColumn(b1, 0); form.Children.Add(b1);
|
||||
var b2 = new Border { BorderBrush = gray, BorderThickness = new Thickness(0, 0, 0, 1), Child = c };
|
||||
Grid.SetRow(b2, r); Grid.SetColumn(b2, 1); form.Children.Add(b2);
|
||||
r++;
|
||||
}
|
||||
var nameBox = new TextBox { BorderThickness = new Thickness(0), VerticalContentAlignment = VerticalAlignment.Center };
|
||||
var typeCmb = new ComboBox { BorderThickness = new Thickness(0) };
|
||||
foreach (var s in new[] { "Enterprise", "SMB", "Government", "Reseller" }) typeCmb.Items.Add(s); typeCmb.SelectedIndex = 0;
|
||||
var regionCmb = new ComboBox { BorderThickness = new Thickness(0) };
|
||||
foreach (var s in new[] { "North", "South", "EMEA", "APAC", "LATAM" }) regionCmb.Items.Add(s); regionCmb.SelectedIndex = 0;
|
||||
var priority = new Slider { Minimum = 1, Maximum = 5, Value = 3, TickFrequency = 1, IsSnapToTickEnabled = true, TickPlacement = TickPlacement.BottomRight, VerticalAlignment = VerticalAlignment.Center };
|
||||
var credit = new Slider { Minimum = 0, Maximum = 100, Value = 40, TickFrequency = 10, TickPlacement = TickPlacement.BottomRight, VerticalAlignment = VerticalAlignment.Center };
|
||||
AddRow("Account Name", nameBox);
|
||||
AddRow("Account Type", typeCmb);
|
||||
AddRow("Region", regionCmb);
|
||||
AddRow("Priority (1-5)", priority);
|
||||
AddRow("Credit Limit", credit);
|
||||
var saveBtn = new Button { Content = "Add Record", FontWeight = FontWeights.Bold };
|
||||
form.RowDefinitions.Add(new RowDefinition { Height = new GridLength(40) });
|
||||
Grid.SetRow(saveBtn, r); Grid.SetColumn(saveBtn, 0); Grid.SetColumnSpan(saveBtn, 2); form.Children.Add(saveBtn);
|
||||
|
||||
// ── right top: line-tool sketch (one straight segment per drag) ─────
|
||||
var ink = new Canvas { Background = Brushes.White, ClipToBounds = true };
|
||||
Point? dn = null; Line preview = null;
|
||||
ink.MouseLeftButtonDown += (s, e) => { dn = e.GetPosition(ink); };
|
||||
ink.MouseMove += (s, e) => { if (dn != null) { var p = e.GetPosition(ink);
|
||||
if (preview == null) { preview = new Line { Stroke = Brushes.MidnightBlue, StrokeThickness = 1, Opacity = 0.45 }; ink.Children.Add(preview); }
|
||||
preview.X1 = dn.Value.X; preview.Y1 = dn.Value.Y; preview.X2 = p.X; preview.Y2 = p.Y; } };
|
||||
ink.MouseLeftButtonUp += (s, e) => { if (dn != null) { var p = e.GetPosition(ink);
|
||||
ink.Children.Add(new Line { Stroke = Brushes.MidnightBlue, StrokeThickness = 2, X1 = dn.Value.X, Y1 = dn.Value.Y, X2 = p.X, Y2 = p.Y });
|
||||
if (preview != null) { ink.Children.Remove(preview); preview = null; }
|
||||
dn = null; } };
|
||||
var sigHdr = new TextBlock { Text = " Signature / Notes", FontWeight = FontWeights.Bold, Background = new SolidColorBrush(Color.FromRgb(0xE1,0xE1,0xE1)), Padding = new Thickness(2) };
|
||||
var sigDock = new DockPanel(); DockPanel.SetDock(sigHdr, Dock.Top); sigDock.Children.Add(sigHdr); sigDock.Children.Add(ink);
|
||||
|
||||
// ── right bottom: spreadsheet ───────────────────────────────────────
|
||||
var dg = new DataGrid { AutoGenerateColumns = false, ItemsSource = rows, IsReadOnly = true, GridLinesVisibility = DataGridGridLinesVisibility.All, HeadersVisibility = DataGridHeadersVisibility.Column };
|
||||
dg.Columns.Add(new DataGridTextColumn { Header = "Account", Binding = new System.Windows.Data.Binding("Account"), Width = new DataGridLength(1, DataGridLengthUnitType.Star) });
|
||||
dg.Columns.Add(new DataGridTextColumn { Header = "Type", Binding = new System.Windows.Data.Binding("Type") });
|
||||
dg.Columns.Add(new DataGridTextColumn { Header = "Region", Binding = new System.Windows.Data.Binding("Region") });
|
||||
dg.Columns.Add(new DataGridTextColumn { Header = "Pri", Binding = new System.Windows.Data.Binding("Pri") });
|
||||
dg.Columns.Add(new DataGridTextColumn { Header = "Credit", Binding = new System.Windows.Data.Binding("Credit") });
|
||||
|
||||
var rightGrid = new Grid();
|
||||
rightGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(150) });
|
||||
rightGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(3) });
|
||||
rightGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
Grid.SetRow(sigDock, 0); rightGrid.Children.Add(sigDock);
|
||||
var gs2 = new GridSplitter { Height = 3, HorizontalAlignment = HorizontalAlignment.Stretch, Background = gray }; Grid.SetRow(gs2, 1); rightGrid.Children.Add(gs2);
|
||||
Grid.SetRow(dg, 2); rightGrid.Children.Add(dg);
|
||||
|
||||
var main = new Grid();
|
||||
main.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(380) });
|
||||
main.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(3) });
|
||||
main.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
Grid.SetColumn(form, 0); main.Children.Add(form);
|
||||
var gs1 = new GridSplitter { Width = 3, Background = gray }; Grid.SetColumn(gs1, 1); main.Children.Add(gs1);
|
||||
Grid.SetColumn(rightGrid, 2); main.Children.Add(rightGrid);
|
||||
|
||||
// status bar
|
||||
var statusBar = new StatusBar();
|
||||
var statusLbl = new StatusBarItem { Content = "Ready" };
|
||||
var recLbl = new StatusBarItem { Content = "Records: 0", HorizontalAlignment = HorizontalAlignment.Right };
|
||||
statusBar.Items.Add(statusLbl);
|
||||
statusBar.Items.Add(new StatusBarItem { Content = "USER: SYSTEM ▌ CONNECTED", HorizontalAlignment = HorizontalAlignment.Right });
|
||||
statusBar.Items.Add(recLbl);
|
||||
|
||||
int records = 0;
|
||||
saveBtn.Click += (s, e) =>
|
||||
{
|
||||
string nm = (nameBox.Text ?? "").Trim(); if (nm.Length == 0) nm = "(unnamed)";
|
||||
rows.Add(new RowVM { Account = nm, Type = typeCmb.Text, Region = regionCmb.Text, Pri = (int)priority.Value, Credit = "$" + ((int)credit.Value * 1000) });
|
||||
records++; recLbl.Content = $"Records: {records}"; statusLbl.Content = $"Saved account '{nm}'.";
|
||||
nameBox.Text = "";
|
||||
};
|
||||
|
||||
var dock = new DockPanel();
|
||||
DockPanel.SetDock(menu, Dock.Top); DockPanel.SetDock(tray, Dock.Top); DockPanel.SetDock(statusBar, Dock.Bottom);
|
||||
dock.Children.Add(menu); dock.Children.Add(tray); dock.Children.Add(statusBar); dock.Children.Add(main);
|
||||
|
||||
var win = new Window { Title = "Meridian CRM — Account Record [WPF]", Width = 960, Height = 600, Content = dock, WindowStartupLocation = WindowStartupLocation.Manual };
|
||||
app.Run(win);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>disable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AssemblyName>wpf-legacy</AssemblyName>
|
||||
<EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; font-family: "Segoe UI", Tahoma, sans-serif; font-size: 13px; background: #f0f0f0; overflow: hidden; }
|
||||
#app { display: grid; grid-template-rows: auto auto 1fr auto; height: 100vh; }
|
||||
/* menu + toolbar */
|
||||
#menu { background: #f7f7f7; border-bottom: 1px solid #c8c8c8; padding: 2px 0; }
|
||||
#menu span { padding: 3px 10px; }
|
||||
#menu span:hover { background: #cde2ff; }
|
||||
#toolbar { background: #eee; border-bottom: 1px solid #c8c8c8; display: flex; }
|
||||
#toolbar button { border: 1px solid transparent; background: transparent; padding: 3px 9px; }
|
||||
#toolbar button:hover { border: 1px solid #b0c4de; background: #e6eefb; }
|
||||
#toolbar .sep { width: 1px; background: #c8c8c8; margin: 2px 3px; }
|
||||
/* main split */
|
||||
#main { display: grid; grid-template-columns: 380px 1px 1fr; overflow: hidden; }
|
||||
#split { background: #c8c8c8; }
|
||||
/* dense form: label | control rows, only cell borders */
|
||||
#form { display: grid; grid-template-columns: 120px 1fr; grid-auto-rows: 30px; background: #fff; align-content: start; }
|
||||
#form .lbl { background: #f0f0f0; border-right: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; padding-left: 8px; }
|
||||
#form .cell { border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; }
|
||||
#form .cell > * { width: 100%; height: 100%; border: 0; background: transparent; padding: 0 6px; font: inherit; }
|
||||
#form input[type=range] { padding: 0; }
|
||||
#saveRow { grid-column: 1 / span 2; }
|
||||
#save { width: 100%; height: 38px; font-weight: bold; }
|
||||
/* right side */
|
||||
#right { display: grid; grid-template-rows: 150px 1px 1fr; overflow: hidden; }
|
||||
#sigHdr { background: #e1e1e1; font-weight: bold; padding: 3px 6px; border-bottom: 1px solid #c8c8c8; }
|
||||
#sigWrap { display: grid; grid-template-rows: auto 1fr; }
|
||||
#doodle { width: 100%; height: 100%; background: #fff; display: block; }
|
||||
table { border-collapse: collapse; width: 100%; background: #fff; }
|
||||
th, td { border: 1px solid #d2d2d2; padding: 2px 6px; text-align: left; font-weight: normal; }
|
||||
th { background: #eef1f5; font-weight: 600; }
|
||||
#gridWrap { overflow: auto; }
|
||||
#status { background: #e8e8e8; border-top: 1px solid #c8c8c8; display: flex; justify-content: space-between; padding: 3px 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="menu"><span>File</span><span>Edit</span><span>View</span><span>Record</span><span>Tools</span><span>Help</span></div>
|
||||
<div id="toolbar">
|
||||
<button>New</button><button>Open</button><button>Save</button><button>Delete</button>
|
||||
<div class="sep"></div><button>◀ Prev</button><button>Next ▶</button>
|
||||
<div class="sep"></div><button>Find</button>
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="form">
|
||||
<div class="lbl">Account Name</div><div class="cell"><input id="acct" type="text" /></div>
|
||||
<div class="lbl">Account Type</div><div class="cell"><select id="type"><option>Enterprise</option><option>SMB</option><option>Government</option><option>Reseller</option></select></div>
|
||||
<div class="lbl">Region</div><div class="cell"><select id="region"><option>North</option><option>South</option><option>EMEA</option><option>APAC</option><option>LATAM</option></select></div>
|
||||
<div class="lbl">Priority (1-5)</div><div class="cell"><input id="pri" type="range" min="1" max="5" value="3" /></div>
|
||||
<div class="lbl">Credit Limit</div><div class="cell"><input id="credit" type="range" min="0" max="100" value="40" /></div>
|
||||
<div id="saveRow" class="cell"><button id="save">Add Record</button></div>
|
||||
</div>
|
||||
<div id="split"></div>
|
||||
<div id="right">
|
||||
<div id="sigWrap"><div id="sigHdr">Signature / Notes</div><canvas id="doodle"></canvas></div>
|
||||
<div id="split"></div>
|
||||
<div id="gridWrap">
|
||||
<table id="grid"><thead><tr><th>Account</th><th>Type</th><th>Region</th><th>Pri</th><th>Credit</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status"><span id="statusMsg">Ready</span><span>USER: SYSTEM ▌ CONNECTED</span><span id="recCount">Records: 0</span></div>
|
||||
</div>
|
||||
<script>
|
||||
// line tool: one straight segment per press-drag-release (down→up)
|
||||
const cv = document.getElementById('doodle');
|
||||
const ctx = cv.getContext('2d');
|
||||
let segs = [], dn = null, cur = null;
|
||||
function redraw() {
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
ctx.strokeStyle = '#191970'; ctx.lineWidth = 2;
|
||||
for (const s of segs) { ctx.beginPath(); ctx.moveTo(s.x0, s.y0); ctx.lineTo(s.x1, s.y1); ctx.stroke(); }
|
||||
if (dn && cur) { ctx.save(); ctx.globalAlpha = 0.45; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(dn.x, dn.y); ctx.lineTo(cur.x, cur.y); ctx.stroke(); ctx.restore(); }
|
||||
}
|
||||
function fit() { cv.width = cv.clientWidth; cv.height = cv.clientHeight; redraw(); }
|
||||
window.addEventListener('resize', fit); requestAnimationFrame(fit);
|
||||
// Pointer events (not mouse): coordinate-routed background injection arrives
|
||||
// as PEN/touch pointers; pointer* covers mouse + pen + touch uniformly.
|
||||
cv.style.touchAction = 'none';
|
||||
cv.addEventListener('pointerdown', e => { dn = { x: e.offsetX, y: e.offsetY }; cur = dn; });
|
||||
cv.addEventListener('pointermove', e => { if (dn) { cur = { x: e.offsetX, y: e.offsetY }; redraw(); } });
|
||||
cv.addEventListener('pointerup', e => { if (dn) { segs.push({ x0: dn.x, y0: dn.y, x1: e.offsetX, y1: e.offsetY }); dn = null; cur = null; redraw(); } });
|
||||
// save
|
||||
let records = 0;
|
||||
const acct = document.getElementById('acct'), tbody = document.querySelector('#grid tbody');
|
||||
document.getElementById('save').addEventListener('click', () => {
|
||||
let nm = (acct.value || '').trim(); if (!nm) nm = '(unnamed)';
|
||||
const tr = document.createElement('tr');
|
||||
const credit = '$' + (document.getElementById('credit').value * 1000);
|
||||
[nm, document.getElementById('type').value, document.getElementById('region').value, document.getElementById('pri').value, credit]
|
||||
.forEach(v => { const td = document.createElement('td'); td.textContent = v; tr.appendChild(td); });
|
||||
tbody.appendChild(tr);
|
||||
records++; document.getElementById('recCount').textContent = `Records: ${records}`;
|
||||
document.getElementById('statusMsg').textContent = `Saved account '${nm}'.`;
|
||||
acct.value = '';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 560,
|
||||
resizable: true,
|
||||
title: 'Meridian CRM — Account Record [Electron]',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: { contextIsolation: true },
|
||||
});
|
||||
win.setMenuBarVisibility(false);
|
||||
win.loadFile(path.join(__dirname, 'index.html'));
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => app.quit());
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "cua-demo-electron-legacy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^39.8.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "legacy-app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "legacy-app"
|
||||
path = "src/main.rs"
|
||||
required-features = ["windows-app"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
windows-app = []
|
||||
|
||||
[dependencies]
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_LibraryLoader",
|
||||
] }
|
||||
@@ -0,0 +1,343 @@
|
||||
//! "Meridian CRM — Account Record" — a dense, realistic Windows office form for
|
||||
//! the cua-driver multi-cursor demo. Menu bar, toolbar, a packed left form
|
||||
//! (Account Name, Account Type / Region dropdowns, Priority / Credit sliders),
|
||||
//! a SAVE button, a signature doodle pad, a spreadsheet grid, and a status bar.
|
||||
//! No padding between elements beyond their 1px borders.
|
||||
//!
|
||||
//! Modes (argv[1]):
|
||||
//! gdi <node> — fully GDI-drawn (NO accessibility tree → cua-driver drives
|
||||
//! it via the pixel path). The Account-Name field and SAVE
|
||||
//! button are drawn; the doodle pad is live.
|
||||
//! master <node> — same chrome, but Account Name + SAVE are real Win32
|
||||
//! controls, instrumented to EMIT the user's action on stdout
|
||||
//! (TYPE<text>, CLICK<rx><ry>) for the orchestrator to replay.
|
||||
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::io::Write;
|
||||
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::{COLORREF, HINSTANCE, HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
BeginPaint, CreateFontW, CreatePen, CreateSolidBrush, DeleteObject, DrawTextW, EndPaint, FillRect,
|
||||
FrameRect, InvalidateRect, LineTo, MoveToEx, SelectObject, SetBkMode, SetTextColor, DT_CENTER, DT_LEFT,
|
||||
DT_SINGLELINE, DT_VCENTER, GetStockObject, HBRUSH, HFONT, PAINTSTRUCT, PS_SOLID, TRANSPARENT, BLACK_BRUSH,
|
||||
};
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
|
||||
// ── dense fractional layout (shared by gdi + master) ──────────────────────────
|
||||
const MENU_B: f64 = 0.05;
|
||||
const TOOL_B: f64 = 0.10;
|
||||
// left form rows
|
||||
const NAME: [f64; 4] = [0.16, 0.110, 0.41, 0.170]; // Account Name field box
|
||||
const TYPE: [f64; 4] = [0.16, 0.175, 0.41, 0.235];
|
||||
const REGION: [f64; 4] = [0.16, 0.240, 0.41, 0.300];
|
||||
const PRIO: [f64; 4] = [0.16, 0.305, 0.41, 0.365];
|
||||
const CREDIT: [f64; 4] = [0.16, 0.370, 0.41, 0.430];
|
||||
const SAVE: [f64; 4] = [0.04, 0.460, 0.40, 0.530]; // SAVE button
|
||||
const DOODLE: [f64; 4] = [0.42, 0.140, 0.99, 0.420];
|
||||
const GRID: [f64; 4] = [0.01, 0.560, 0.99, 0.930];
|
||||
const STATUS_T: f64 = 0.94;
|
||||
// SAVE button relative center (master emits this for the CLICK event).
|
||||
const SAVE_CX: f64 = (SAVE[0] + SAVE[2]) / 2.0;
|
||||
const SAVE_CY: f64 = (SAVE[1] + SAVE[3]) / 2.0;
|
||||
|
||||
const ID_EDIT: isize = 1001;
|
||||
const ID_BUTTON: isize = 1002;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Mode { Gdi, Master }
|
||||
|
||||
struct State {
|
||||
mode: Mode,
|
||||
node: String,
|
||||
records: Vec<[String; 5]>,
|
||||
name: String,
|
||||
// line tool: one straight segment per press-drag-release (down→up)
|
||||
segs: Vec<(POINT, POINT)>,
|
||||
down: Option<POINT>,
|
||||
cur: POINT,
|
||||
hedit: HWND,
|
||||
hbtn: HWND,
|
||||
f_title: HFONT,
|
||||
f_label: HFONT,
|
||||
f_mono: HFONT,
|
||||
cw: i32,
|
||||
ch: i32,
|
||||
}
|
||||
|
||||
thread_local! { static STATE: RefCell<Option<State>> = const { RefCell::new(None) }; }
|
||||
|
||||
fn emit(line: &str) { let _ = writeln!(std::io::stdout(), "{line}"); let _ = std::io::stdout().flush(); }
|
||||
fn wide(s: &str) -> Vec<u16> { s.encode_utf16().chain(std::iter::once(0)).collect() }
|
||||
fn fr(cw: i32, ch: i32, r: [f64; 4]) -> RECT {
|
||||
RECT { left: (cw as f64 * r[0]) as i32, top: (ch as f64 * r[1]) as i32,
|
||||
right: (cw as f64 * r[2]) as i32, bottom: (ch as f64 * r[3]) as i32 }
|
||||
}
|
||||
fn band(cw: i32, ch: i32, y0: f64, y1: f64) -> RECT {
|
||||
RECT { left: 0, top: (ch as f64 * y0) as i32, right: cw, bottom: (ch as f64 * y1) as i32 }
|
||||
}
|
||||
fn pt_in(r: &RECT, x: i32, y: i32) -> bool { x >= r.left && x < r.right && y >= r.top && y < r.bottom }
|
||||
unsafe fn mk_font(h: i32, w: i32, face: PCWSTR) -> HFONT { CreateFontW(h, 0, 0, 0, w, 0, 0, 0, 0, 0, 0, 0, 0, face) }
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mode = if args.get(1).map(|s| s.as_str()) == Some("master") { Mode::Master } else { Mode::Gdi };
|
||||
let node = args.get(2).cloned().unwrap_or_else(|| match mode { Mode::Master => "Master".into(), Mode::Gdi => "Win32 GDI".into() });
|
||||
|
||||
unsafe {
|
||||
let hmod = GetModuleHandleW(None).unwrap();
|
||||
let class = w!("MeridianCrmForm");
|
||||
let bg = CreateSolidBrush(COLORREF(0x00F0F0F0));
|
||||
let wc = WNDCLASSW { lpfnWndProc: Some(wnd_proc), hInstance: hmod.into(), lpszClassName: class,
|
||||
hbrBackground: bg, hCursor: LoadCursorW(None, IDC_ARROW).unwrap_or_default(), ..Default::default() };
|
||||
RegisterClassW(&wc);
|
||||
|
||||
STATE.with(|s| *s.borrow_mut() = Some(State { mode, node: node.clone(), records: Vec::new(),
|
||||
name: String::new(), segs: Vec::new(), down: None, cur: POINT { x: 0, y: 0 }, hedit: HWND::default(), hbtn: HWND::default(),
|
||||
f_title: HFONT::default(), f_label: HFONT::default(), f_mono: HFONT::default(), cw: 0, ch: 0 }));
|
||||
|
||||
let title = format!("Meridian CRM — Account Record [{node}]");
|
||||
let tw = wide(&title);
|
||||
let hwnd = CreateWindowExW(WINDOW_EX_STYLE(0), class, PCWSTR(tw.as_ptr()), WS_OVERLAPPEDWINDOW,
|
||||
CW_USEDEFAULT, CW_USEDEFAULT, 960, 600, None, None, HINSTANCE(hmod.0), None).expect("CreateWindowExW");
|
||||
let _ = ShowWindow(hwnd, SW_SHOWNORMAL);
|
||||
|
||||
let mut msg = MSG::default();
|
||||
while GetMessageW(&mut msg, None, 0, 0).as_bool() { let _ = TranslateMessage(&msg); DispatchMessageW(&msg); }
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn create_master_controls(parent: HWND) {
|
||||
let hinst = HINSTANCE(GetModuleHandleW(None).unwrap().0);
|
||||
let hedit = CreateWindowExW(WS_EX_CLIENTEDGE, w!("EDIT"), w!(""),
|
||||
WS_CHILD | WS_VISIBLE | WINDOW_STYLE(ES_AUTOHSCROLL as u32),
|
||||
0, 0, 10, 10, parent, HMENU(ID_EDIT as *mut core::ffi::c_void), hinst, None).unwrap_or_default();
|
||||
let hbtn = CreateWindowExW(WINDOW_EX_STYLE(0), w!("BUTTON"), w!("Add Record"),
|
||||
WS_CHILD | WS_VISIBLE | WINDOW_STYLE(BS_PUSHBUTTON as u32),
|
||||
0, 0, 10, 10, parent, HMENU(ID_BUTTON as *mut core::ffi::c_void), hinst, None).unwrap_or_default();
|
||||
STATE.with(|s| if let Some(st) = s.borrow_mut().as_mut() { st.hedit = hedit; st.hbtn = hbtn; });
|
||||
}
|
||||
|
||||
unsafe fn relayout(hwnd: HWND, cw: i32, ch: i32) {
|
||||
STATE.with(|s| {
|
||||
let mut b = s.borrow_mut(); let Some(st) = b.as_mut() else { return };
|
||||
st.cw = cw; st.ch = ch;
|
||||
for f in [st.f_title, st.f_label, st.f_mono] { if !f.is_invalid() { let _ = DeleteObject(f); } }
|
||||
let seg = wide("Segoe UI"); let mono = wide("Consolas");
|
||||
st.f_title = mk_font(-(ch / 34).clamp(12, 22), 700, PCWSTR(seg.as_ptr()));
|
||||
st.f_label = mk_font(-(ch / 40).clamp(11, 18), 400, PCWSTR(seg.as_ptr()));
|
||||
st.f_mono = mk_font(-(ch / 44).clamp(10, 16), 400, PCWSTR(mono.as_ptr()));
|
||||
if st.mode == Mode::Master && !st.hedit.0.is_null() {
|
||||
let n = fr(cw, ch, NAME);
|
||||
let _ = MoveWindow(st.hedit, n.left + 2, n.top + 2, n.right - n.left - 4, n.bottom - n.top - 4, true);
|
||||
let sv = fr(cw, ch, SAVE);
|
||||
let _ = MoveWindow(st.hbtn, sv.left, sv.top, sv.right - sv.left, sv.bottom - sv.top, true);
|
||||
SendMessageW(st.hedit, WM_SETFONT, WPARAM(st.f_label.0 as usize), LPARAM(1));
|
||||
SendMessageW(st.hbtn, WM_SETFONT, WPARAM(st.f_label.0 as usize), LPARAM(1));
|
||||
}
|
||||
});
|
||||
let _ = InvalidateRect(hwnd, None, true);
|
||||
}
|
||||
|
||||
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
|
||||
unsafe {
|
||||
match msg {
|
||||
WM_CREATE => { if STATE.with(|s| s.borrow().as_ref().map(|st| st.mode)) == Some(Mode::Master) { create_master_controls(hwnd); } LRESULT(0) }
|
||||
WM_SIZE => { let (cw, ch) = ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32); if cw > 0 && ch > 0 { relayout(hwnd, cw, ch); } LRESULT(0) }
|
||||
WM_COMMAND => { if (wp.0 & 0xFFFF) as isize == ID_BUTTON && ((wp.0 >> 16) & 0xFFFF) as u32 == BN_CLICKED { on_submit(hwnd); } LRESULT(0) }
|
||||
WM_LBUTTONDOWN => {
|
||||
let (x, y) = ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32);
|
||||
let (mode, cw, ch) = STATE.with(|s| { let b = s.borrow(); let st = b.as_ref().unwrap(); (st.mode, st.cw, st.ch) });
|
||||
if mode == Mode::Gdi && pt_in(&fr(cw, ch, SAVE), x, y) { commit(hwnd); }
|
||||
else if pt_in(&fr(cw, ch, DOODLE), x, y) {
|
||||
// line tool: remember the press point; commit on button-up.
|
||||
STATE.with(|s| if let Some(st) = s.borrow_mut().as_mut() { st.down = Some(POINT { x, y }); st.cur = POINT { x, y }; });
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_MOUSEMOVE => {
|
||||
if (wp.0 & 0x0001) != 0 {
|
||||
let (x, y) = ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32);
|
||||
let go = STATE.with(|s| { let mut b = s.borrow_mut(); let st = b.as_mut().unwrap();
|
||||
if st.down.is_some() { st.cur = POINT { x, y }; true } else { false } });
|
||||
if go { let (cw, ch) = STATE.with(|s| { let b = s.borrow(); (b.as_ref().unwrap().cw, b.as_ref().unwrap().ch) });
|
||||
let d = fr(cw, ch, DOODLE); let _ = InvalidateRect(hwnd, Some(&d), false); }
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_LBUTTONUP => {
|
||||
let (x, y) = ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32);
|
||||
STATE.with(|s| if let Some(st) = s.borrow_mut().as_mut() {
|
||||
if let Some(p0) = st.down.take() { st.segs.push((p0, POINT { x, y })); } });
|
||||
let (cw, ch) = STATE.with(|s| { let b = s.borrow(); (b.as_ref().unwrap().cw, b.as_ref().unwrap().ch) });
|
||||
let d = fr(cw, ch, DOODLE); let _ = InvalidateRect(hwnd, Some(&d), false);
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_CHAR => {
|
||||
if STATE.with(|s| s.borrow().as_ref().map(|st| st.mode)) == Some(Mode::Gdi) {
|
||||
let ch = char::from_u32(wp.0 as u32).unwrap_or('\0');
|
||||
STATE.with(|s| if let Some(st) = s.borrow_mut().as_mut() {
|
||||
match ch { '\u{8}' => { st.name.pop(); }, '\r' | '\n' => {}, c if !c.is_control() => st.name.push(c), _ => {} } });
|
||||
let (cw, ch2) = STATE.with(|s| { let b = s.borrow(); (b.as_ref().unwrap().cw, b.as_ref().unwrap().ch) });
|
||||
let n = fr(cw, ch2, NAME); let _ = InvalidateRect(hwnd, Some(&n), false);
|
||||
}
|
||||
LRESULT(0)
|
||||
}
|
||||
WM_PAINT => { paint(hwnd); LRESULT(0) }
|
||||
WM_DESTROY => { PostQuitMessage(0); LRESULT(0) }
|
||||
_ => DefWindowProcW(hwnd, msg, wp, lp),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn on_submit(hwnd: HWND) {
|
||||
let text = STATE.with(|s| { let b = s.borrow(); let st = b.as_ref()?; if st.hedit.0.is_null() { return None; }
|
||||
let len = GetWindowTextLengthW(st.hedit); let mut buf = vec![0u16; (len + 1) as usize];
|
||||
let n = GetWindowTextW(st.hedit, &mut buf); Some(String::from_utf16_lossy(&buf[..n as usize])) });
|
||||
if let Some(t) = text {
|
||||
emit(&format!("TYPE\t{t}"));
|
||||
emit(&format!("CLICK\t{SAVE_CX:.4}\t{SAVE_CY:.4}"));
|
||||
STATE.with(|s| if let Some(st) = s.borrow_mut().as_mut() { st.name = t; });
|
||||
commit(hwnd);
|
||||
STATE.with(|s| if let Some(st) = s.borrow().as_ref() { if !st.hedit.0.is_null() { let _ = SetWindowTextW(st.hedit, w!("")); } });
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn commit(hwnd: HWND) {
|
||||
STATE.with(|s| if let Some(st) = s.borrow_mut().as_mut() {
|
||||
let nm = if st.name.trim().is_empty() { "(unnamed)".into() } else { st.name.trim().chars().take(22).collect::<String>() };
|
||||
st.records.push([nm, "Enterprise".into(), "North".into(), "3".into(), "$40,000".into()]);
|
||||
st.name.clear();
|
||||
});
|
||||
let _ = InvalidateRect(hwnd, None, true);
|
||||
}
|
||||
|
||||
unsafe fn line(hdc: windows::Win32::Graphics::Gdi::HDC, x: i32, y: i32, text: &str) {
|
||||
let mut r = RECT { left: x, top: y, right: x + 6000, bottom: y + 60 }; let mut t = wide(text);
|
||||
DrawTextW(hdc, &mut t, &mut r, DT_LEFT | DT_SINGLELINE);
|
||||
}
|
||||
|
||||
unsafe fn paint(hwnd: HWND) {
|
||||
let mut ps = PAINTSTRUCT::default();
|
||||
let hdc = BeginPaint(hwnd, &mut ps);
|
||||
SetBkMode(hdc, TRANSPARENT);
|
||||
STATE.with(|s| {
|
||||
let b = s.borrow(); let Some(st) = b.as_ref() else { return };
|
||||
let (cw, ch) = (st.cw.max(1), st.ch.max(1));
|
||||
let face = CreateSolidBrush(COLORREF(0x00F0F0F0));
|
||||
let bar = CreateSolidBrush(COLORREF(0x00E8E8E8));
|
||||
let hdr = CreateSolidBrush(COLORREF(0x00F5F1EE));
|
||||
let white = CreateSolidBrush(COLORREF(0x00FFFFFF));
|
||||
let line_c = CreateSolidBrush(COLORREF(0x00D2D2D2));
|
||||
let labelbg = CreateSolidBrush(COLORREF(0x00F0F0F0));
|
||||
let black = GetStockObject(BLACK_BRUSH);
|
||||
let sel = CreateSolidBrush(COLORREF(0x00F5C28A)); // accent for slider thumb / save
|
||||
|
||||
let frame = |r: &RECT| { FrameRect(hdc, r, HBRUSH(black.0)); };
|
||||
let hline = |y: i32| { let r = RECT { left: 0, top: y, right: cw, bottom: y + 1 }; FillRect(hdc, &r, line_c); };
|
||||
let vline = |x: i32, y0: i32, y1: i32| { let r = RECT { left: x, top: y0, right: x + 1, bottom: y1 }; FillRect(hdc, &r, line_c); };
|
||||
|
||||
// menu bar
|
||||
let mut m = band(cw, ch, 0.0, MENU_B); FillRect(hdc, &m, bar);
|
||||
SelectObject(hdc, st.f_label); SetTextColor(hdc, COLORREF(0x00000000));
|
||||
let mut mt = wide(" File Edit View Record Tools Help");
|
||||
DrawTextW(hdc, &mut mt, &mut m, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
hline((ch as f64 * MENU_B) as i32);
|
||||
// toolbar
|
||||
let mut tb = band(cw, ch, MENU_B, TOOL_B); FillRect(hdc, &tb, face);
|
||||
let mut tt = wide(" New Open Save Delete │ ◀ Prev Next ▶ │ Find");
|
||||
DrawTextW(hdc, &mut tt, &mut tb, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
hline((ch as f64 * TOOL_B) as i32);
|
||||
|
||||
// ── left form ──
|
||||
let label = |r: [f64; 4], text: &str| {
|
||||
let lr = RECT { left: (cw as f64 * 0.01) as i32, top: (ch as f64 * r[1]) as i32,
|
||||
right: (cw as f64 * (r[0] - 0.005)) as i32, bottom: (ch as f64 * r[3]) as i32 };
|
||||
FillRect(hdc, &lr, labelbg);
|
||||
let mut t = wide(text);
|
||||
let mut tr = RECT { left: lr.left + 6, ..lr };
|
||||
DrawTextW(hdc, &mut t, &mut tr, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
};
|
||||
let dropdown = |r: [f64; 4], val: &str| {
|
||||
let bx = fr(cw, ch, r); FillRect(hdc, &bx, white); frame(&bx);
|
||||
let mut tr = bx; tr.left += 6; tr.right -= 24; let mut t = wide(val);
|
||||
DrawTextW(hdc, &mut t, &mut tr, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
let ar = RECT { left: bx.right - 20, top: bx.top, right: bx.right, bottom: bx.bottom };
|
||||
FillRect(hdc, &ar, bar); frame(&ar); let mut a = wide("▼");
|
||||
DrawTextW(hdc, &mut a, &mut ar.clone(), DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||
};
|
||||
let slider = |r: [f64; 4], frac: f64| {
|
||||
let bx = fr(cw, ch, r); let midy = (bx.top + bx.bottom) / 2;
|
||||
let track = RECT { left: bx.left + 4, top: midy - 1, right: bx.right - 4, bottom: midy + 1 }; FillRect(hdc, &track, line_c);
|
||||
let tx = bx.left + 4 + ((bx.right - bx.left - 8) as f64 * frac) as i32;
|
||||
let thumb = RECT { left: tx - 4, top: bx.top + 6, right: tx + 4, bottom: bx.bottom - 6 }; FillRect(hdc, &thumb, sel); frame(&thumb);
|
||||
};
|
||||
SetTextColor(hdc, COLORREF(0x00000000));
|
||||
label(NAME, "Account Name"); label(TYPE, "Account Type"); label(REGION, "Region");
|
||||
label(PRIO, "Priority"); label(CREDIT, "Credit Limit");
|
||||
|
||||
if st.mode == Mode::Gdi {
|
||||
let n = fr(cw, ch, NAME); FillRect(hdc, &n, white); frame(&n);
|
||||
let mut tr = n; tr.left += 6; let mut nm = wide(&st.name);
|
||||
DrawTextW(hdc, &mut nm, &mut tr, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
let sv = fr(cw, ch, SAVE); FillRect(hdc, &sv, sel); frame(&sv);
|
||||
let mut bt = wide("Add Record"); SelectObject(hdc, st.f_title);
|
||||
DrawTextW(hdc, &mut bt, &mut sv.clone(), DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||
SelectObject(hdc, st.f_label);
|
||||
}
|
||||
// dropdowns + sliders are visual in both modes
|
||||
dropdown(TYPE, "Enterprise"); dropdown(REGION, "North");
|
||||
slider(PRIO, 0.5); slider(CREDIT, 0.4);
|
||||
|
||||
// ── signature doodle ──
|
||||
let dh = RECT { left: (cw as f64 * DOODLE[0]) as i32, top: (ch as f64 * 0.105) as i32,
|
||||
right: (cw as f64 * DOODLE[2]) as i32, bottom: (ch as f64 * DOODLE[1]) as i32 };
|
||||
FillRect(hdc, &dh, hdr); let mut sh = wide(" Signature / Notes");
|
||||
SelectObject(hdc, st.f_title); DrawTextW(hdc, &mut sh, &mut dh.clone(), DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
SelectObject(hdc, st.f_label);
|
||||
let dz = fr(cw, ch, DOODLE); FillRect(hdc, &dz, white); frame(&dz);
|
||||
// line-tool segments (blue, 2px) + thin rubber-band preview while drawing
|
||||
let pen = CreatePen(PS_SOLID, 2, COLORREF(0x00701919));
|
||||
let old_pen = SelectObject(hdc, pen);
|
||||
for (a, b) in &st.segs { let _ = MoveToEx(hdc, a.x, a.y, None); let _ = LineTo(hdc, b.x, b.y); }
|
||||
if let Some(a) = st.down { let _ = MoveToEx(hdc, a.x, a.y, None); let _ = LineTo(hdc, st.cur.x, st.cur.y); }
|
||||
SelectObject(hdc, old_pen); let _ = DeleteObject(pen);
|
||||
|
||||
// ── spreadsheet ──
|
||||
let g = fr(cw, ch, GRID); FillRect(hdc, &g, white); frame(&g);
|
||||
let cols = [("Account", 0.45), ("Type", 0.62), ("Region", 0.76), ("Pri", 0.85), ("Credit", 0.99)];
|
||||
let cx = |fx: f64| (cw as f64 * (GRID[0] + (GRID[2] - GRID[0]) * ((fx - 0.0) / 1.0))) as i32; // placeholder
|
||||
let _ = cx;
|
||||
// column x positions in absolute (fractions are of full width but cols listed as cumulative within grid)
|
||||
let gx = |fx: f64| (cw as f64 * fx) as i32;
|
||||
let row_h = ((st.ch as f64 / 44.0).clamp(10.0, 16.0) * 1.7) as i32;
|
||||
// header
|
||||
let hrow = RECT { left: g.left, top: g.top, right: g.right, bottom: g.top + row_h }; FillRect(hdc, &hrow, hdr);
|
||||
SelectObject(hdc, st.f_label);
|
||||
let mut prev = g.left + 6;
|
||||
for (name, fx) in cols { let mut t = wide(name); let mut tr = RECT { left: prev, top: g.top, right: gx(fx), bottom: g.top + row_h };
|
||||
DrawTextW(hdc, &mut t, &mut tr, DT_LEFT | DT_VCENTER | DT_SINGLELINE); vline(gx(fx), g.top, g.bottom); prev = gx(fx) + 6; }
|
||||
hline(g.top + row_h);
|
||||
// rows
|
||||
SelectObject(hdc, st.f_mono);
|
||||
let mut y = g.top + row_h + 2;
|
||||
for rec in &st.records {
|
||||
if y + row_h > g.bottom { break; }
|
||||
let xs = [g.left + 6, gx(0.45) + 6, gx(0.62) + 6, gx(0.76) + 6, gx(0.85) + 6];
|
||||
for (i, val) in rec.iter().enumerate() { line(hdc, xs[i], y + 2, val); }
|
||||
y += row_h; hline(y);
|
||||
}
|
||||
|
||||
// ── status bar ──
|
||||
let mut sb = band(cw, ch, STATUS_T, 1.0); FillRect(hdc, &sb, bar); hline((ch as f64 * STATUS_T) as i32);
|
||||
SelectObject(hdc, st.f_label); SetTextColor(hdc, COLORREF(0x00000000));
|
||||
let mut sbt = wide(&format!(" Ready │ Records: {} │ USER: SYSTEM ▌ CONNECTED │ [{}]", st.records.len(), st.node));
|
||||
DrawTextW(hdc, &mut sbt, &mut sb, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
|
||||
|
||||
for o in [face, bar, hdr, white, line_c, labelbg, sel] { let _ = DeleteObject(o); }
|
||||
});
|
||||
let _ = EndPaint(hwnd, &ps);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "orchestrator"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "orchestrator"
|
||||
path = "src/main.rs"
|
||||
required-features = ["windows-app"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
windows-app = []
|
||||
|
||||
[dependencies]
|
||||
# PNG decode + resize for the screenshot-based maze line-diff verification.
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Graphics_Dwm",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_JobObjects",
|
||||
"Win32_Security",
|
||||
] }
|
||||
@@ -0,0 +1,567 @@
|
||||
//! Multi-cursor background computer-use demo orchestrator.
|
||||
//!
|
||||
//! Launches the "National Records System" terminal in five UI frameworks: a 2x2
|
||||
//! grid of background windows (each = half the work area) around one foreground
|
||||
//! "master" in the middle that overlaps all four. When the human submits a
|
||||
//! record in the master, the master emits the action on stdout; this
|
||||
//! orchestrator replays it onto all four background corners concurrently, each
|
||||
//! via its OWN cua-driver session (= its own uniquely-coloured agent cursor),
|
||||
//! in the background — no window raised, the user's cursor never moved.
|
||||
//!
|
||||
//! Driving is element-based where an accessibility tree exists (set_value for
|
||||
//! the SUBJECT-NAME field + UIA-Invoke for SUBMIT — no foreground steal, no
|
||||
//! SendInput) and pixel-based for the GDI corner that has no a11y tree. Each
|
||||
//! a11y corner is VERIFIED by reading the records grid back: if the typed
|
||||
//! record didn't land, it's logged as FAIL.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc::{channel, Sender};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use windows::core::PWSTR;
|
||||
use windows::Win32::Foundation::{BOOL, HANDLE, HWND, LPARAM, POINT, RECT, TRUE};
|
||||
use windows::Win32::Graphics::Dwm::{DwmGetWindowAttribute, DWMWA_EXTENDED_FRAME_BOUNDS};
|
||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject,
|
||||
JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetClientRect, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible,
|
||||
SetForegroundWindow, SetWindowPos, HWND_TOP, SWP_NOACTIVATE, SWP_NOZORDER, SWP_SHOWWINDOW,
|
||||
};
|
||||
|
||||
const FIELD_FRAC: (f64, f64) = (0.28, 0.145); // Account-Name field (GDI pixel fallback)
|
||||
|
||||
// ── maze (line tool) ───────────────────────────────────────────────────────────
|
||||
// A spiral maze as straight segments in [0,1] of the drawing REGION. cua-driver
|
||||
// draws each as one press-drag-release (no curves = "line tool only"). The same
|
||||
// segments rasterized are the reference the screenshot is diffed against.
|
||||
const MAZE: [(f64, f64, f64, f64); 7] = [
|
||||
(0.10, 0.12, 0.90, 0.12),
|
||||
(0.90, 0.12, 0.90, 0.88),
|
||||
(0.90, 0.88, 0.26, 0.88),
|
||||
(0.26, 0.88, 0.26, 0.40),
|
||||
(0.26, 0.40, 0.66, 0.40),
|
||||
(0.66, 0.40, 0.66, 0.64),
|
||||
(0.66, 0.64, 0.46, 0.64),
|
||||
];
|
||||
// Client-fraction rect of the window that sits INSIDE every framework's drawing
|
||||
// pad (all four put the doodle top-right). cua-driver draws here and the
|
||||
// verifier crops the same rect — so the rasterized reference aligns regardless
|
||||
// of where each framework actually lays its canvas out. Tuned via screenshots.
|
||||
// Kept inside the SHORTEST drawing pad: WPF/Electron host theirs in a fixed
|
||||
// ~150px row, so the usable band ends well above the spreadsheet below it.
|
||||
// Overshooting here drops the lower maze onto the grid (and selects rows).
|
||||
const REGION: [f64; 4] = [0.470, 0.180, 0.900, 0.295]; // l, t, r, b (client fractions)
|
||||
// A drawn maze must score at least this (F1 of ink overlap vs the reference,
|
||||
// dilated) to count as COMMITTED; below it = FAIL.
|
||||
const MAZE_PASS: f64 = 0.45;
|
||||
|
||||
// ── kill-on-exit job ──────────────────────────────────────────────────────────
|
||||
static JOB: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
fn job() -> HANDLE {
|
||||
let raw = *JOB.get_or_init(|| unsafe {
|
||||
let h = CreateJobObjectW(None, windows::core::PCWSTR::null()).expect("CreateJobObjectW");
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
let _ = SetInformationJobObject(h, JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const core::ffi::c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32);
|
||||
h.0 as usize
|
||||
});
|
||||
HANDLE(raw as *mut core::ffi::c_void)
|
||||
}
|
||||
fn assign_to_job(child: &Child) {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
unsafe { let _ = AssignProcessToJobObject(job(), HANDLE(child.as_raw_handle() as *mut core::ffi::c_void)); }
|
||||
}
|
||||
|
||||
struct Corner {
|
||||
title: &'static str, // unique substring to find the window
|
||||
session: &'static str, // palette name -> cursor color
|
||||
hwnd: HWND,
|
||||
pid: u32,
|
||||
field_idx: Option<u64>, // UIA element for the SUBJECT-NAME field (a11y corners)
|
||||
submit_idx: Option<u64>,// UIA element for the SUBMIT button
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let demo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf();
|
||||
let repo_root = demo_root.parent().unwrap().parent().unwrap().to_path_buf();
|
||||
|
||||
let cua = std::env::var("CUA_DRIVER_EXE").map(PathBuf::from)
|
||||
.unwrap_or_else(|_| repo_root.join("libs/cua-driver/rust/target/debug/cua-driver.exe"));
|
||||
let legacy_app = std::env::var("LEGACY_APP_EXE").map(PathBuf::from)
|
||||
.unwrap_or_else(|_| demo_root.join("target/debug/legacy-app.exe"));
|
||||
if !cua.exists() { eprintln!("cua-driver.exe not found at {cua:?}"); std::process::exit(1); }
|
||||
if !legacy_app.exists() { eprintln!("legacy-app.exe not found at {legacy_app:?}"); std::process::exit(1); }
|
||||
|
||||
eprintln!("[orch] starting cua-driver daemon…");
|
||||
let mut daemon = Command::new(&cua).arg("serve").stdout(Stdio::null()).stderr(Stdio::null())
|
||||
.spawn().expect("spawn cua-driver serve");
|
||||
assign_to_job(&daemon);
|
||||
thread::sleep(Duration::from_millis(1500));
|
||||
|
||||
// Launch corners + master.
|
||||
let mut kids: Vec<Child> = Vec::new();
|
||||
let mut launch = |kids: &mut Vec<Child>, cmd: &mut Command| {
|
||||
if let Ok(c) = cmd.stdout(Stdio::null()).stderr(Stdio::null()).spawn() { assign_to_job(&c); kids.push(c); }
|
||||
};
|
||||
launch(&mut kids, Command::new(&legacy_app).args(["gdi", "Win32 GDI (no a11y)"]));
|
||||
let winforms = demo_root.join("dotnet/winforms/bin/Debug/net10.0-windows/winforms-legacy.exe");
|
||||
if winforms.exists() { launch(&mut kids, &mut Command::new(&winforms)); } else { eprintln!("[orch] (skip) winforms missing"); }
|
||||
let wpf = demo_root.join("dotnet/wpf/bin/Debug/net10.0-windows/wpf-legacy.exe");
|
||||
if wpf.exists() { launch(&mut kids, &mut Command::new(&wpf)); } else { eprintln!("[orch] (skip) wpf missing"); }
|
||||
let electron_bin = demo_root.join("electron/node_modules/.bin/electron.cmd");
|
||||
if electron_bin.exists() {
|
||||
let mut c = Command::new(&electron_bin); c.arg(".").current_dir(demo_root.join("electron"));
|
||||
launch(&mut kids, &mut c);
|
||||
} else { eprintln!("[orch] (skip) electron not installed"); }
|
||||
|
||||
let mut master = Command::new(&legacy_app).args(["master", "Master (Win32 controls)"])
|
||||
.stdout(Stdio::piped()).stderr(Stdio::null()).spawn().expect("spawn master");
|
||||
assign_to_job(&master);
|
||||
let master_out = master.stdout.take().unwrap();
|
||||
|
||||
thread::sleep(Duration::from_secs(3)); // window + electron warmup
|
||||
|
||||
let mut corners = vec![
|
||||
Corner { title: "Win32 GDI", session: "crimson", hwnd: HWND::default(), pid: 0, field_idx: None, submit_idx: None },
|
||||
Corner { title: "WinForms", session: "amber", hwnd: HWND::default(), pid: 0, field_idx: None, submit_idx: None },
|
||||
Corner { title: "WPF", session: "aqua", hwnd: HWND::default(), pid: 0, field_idx: None, submit_idx: None },
|
||||
Corner { title: "Electron", session: "mint_lime", hwnd: HWND::default(), pid: 0, field_idx: None, submit_idx: None },
|
||||
];
|
||||
for c in corners.iter_mut() {
|
||||
if let Some((h, pid)) = find_window_by_title(c.title) { c.hwnd = h; c.pid = pid; }
|
||||
else { eprintln!("[orch] (skip) no window for '{}'", c.title); }
|
||||
}
|
||||
corners.retain(|c| !c.hwnd.0.is_null());
|
||||
let master_hwnd = find_window_by_title("Master (Win32").map(|(h, _)| h);
|
||||
|
||||
// Layout: each window half the work area; corners tile quadrants, master centered on top.
|
||||
let wa = work_area();
|
||||
let ww = (wa.right - wa.left) / 2;
|
||||
let wh = (wa.bottom - wa.top) / 2;
|
||||
let positions = [(wa.left, wa.top), (wa.left + ww, wa.top), (wa.left, wa.top + wh), (wa.left + ww, wa.top + wh)];
|
||||
for (i, c) in corners.iter().enumerate() { let (x, y) = positions[i % 4]; place(c.hwnd, x, y, ww, wh, false); }
|
||||
if let Some(mh) = master_hwnd {
|
||||
// Master is smaller than a quadrant so it overlaps the central seam of
|
||||
// all four corners without hiding their forms (which sit at the
|
||||
// quadrant centers, outside this box). Centered on the work area.
|
||||
let (mw, mh2) = (ww / 2, wh / 2);
|
||||
let (mx, my) = (wa.left + ww - mw / 2, wa.top + wh - mh2 / 2);
|
||||
place(mh, mx, my, mw, mh2, true);
|
||||
unsafe { let _ = SetForegroundWindow(mh); }
|
||||
}
|
||||
thread::sleep(Duration::from_millis(800)); // let resized layouts settle
|
||||
|
||||
// Discover UIA elements per corner (field + SUBMIT). GDI has none -> pixel.
|
||||
for c in corners.iter_mut() {
|
||||
let tree = run_call_out(&cua, "get_window_state",
|
||||
&format!(r#"{{"pid":{},"window_id":{},"capture_mode":"ax","session":"{}"}}"#, c.pid, c.hwnd.0 as isize, c.session));
|
||||
c.field_idx = find_idx(&tree, |line| line.contains("] Edit"));
|
||||
c.submit_idx = find_idx(&tree, |line| line.contains("] Button") && line.to_uppercase().contains("ADD RECORD"));
|
||||
eprintln!("[orch] {:<9} session={:<9} field={:?} submit={:?} {}",
|
||||
c.title, c.session, c.field_idx, c.submit_idx,
|
||||
if c.field_idx.is_some() { "(a11y: set_value+invoke)" } else { "(no a11y: pixel)" });
|
||||
// Pre-arm a coloured cursor for this session.
|
||||
let _ = run_call(&cua, "set_agent_cursor_enabled", &format!(r#"{{"enabled":true,"session":"{}"}}"#, c.session));
|
||||
}
|
||||
|
||||
// One driver thread per corner.
|
||||
let mut senders: Vec<Sender<Action>> = Vec::new();
|
||||
let mut handles = Vec::new();
|
||||
for c in &corners {
|
||||
let (tx, rx) = channel::<Action>();
|
||||
senders.push(tx);
|
||||
let cua = cua.clone();
|
||||
let pid = c.pid;
|
||||
let hwnd_addr = c.hwnd.0 as isize;
|
||||
let session = c.session.to_string();
|
||||
let title = c.title.to_string();
|
||||
handles.push(thread::spawn(move || {
|
||||
let hwnd = HWND(hwnd_addr as *mut _);
|
||||
let ax = format!(r#"{{"pid":{pid},"window_id":{hwnd_addr},"capture_mode":"ax","session":"{session}"}}"#);
|
||||
let mut seen_records: i64 = -1;
|
||||
for act in rx {
|
||||
// Re-discover element indices on a FRESH snapshot each action —
|
||||
// a11y vs pixel is decided per action (Chromium's tree appears
|
||||
// late and re-numbers as rows are added; GDI never has a tree).
|
||||
let tree = run_call_out(&cua, "get_window_state", &ax);
|
||||
let field = find_idx(&tree, |l| l.contains("] Edit"));
|
||||
let submit = find_idx(&tree, |l| l.contains("] Button") && l.to_uppercase().contains("ADD RECORD"));
|
||||
match act {
|
||||
Action::Type { text } => {
|
||||
let ok = match field {
|
||||
// a11y: plain type_text with element_index — cua-driver
|
||||
// auto-routes to UIA ValuePattern.SetValue on its own.
|
||||
Some(idx) => run_call(&cua, "type_text", &format!(
|
||||
r#"{{"pid":{pid},"window_id":{hwnd_addr},"element_index":{idx},"text":"{}","session":"{session}"}}"#, json_escape(&text))),
|
||||
// no a11y (GDI): focus the field by pixel, then WM_CHAR.
|
||||
None => {
|
||||
if let Some((x, y)) = client_rel_to_local_px(hwnd, FIELD_FRAC.0, FIELD_FRAC.1) {
|
||||
let _ = run_call(&cua, "click", &format!(
|
||||
r#"{{"pid":{pid},"window_id":{hwnd_addr},"x":{x},"y":{y},"session":"{session}"}}"#));
|
||||
}
|
||||
run_call(&cua, "type_text", &format!(
|
||||
r#"{{"pid":{pid},"window_id":{hwnd_addr},"text":"{}","session":"{session}"}}"#, json_escape(&text)))
|
||||
}
|
||||
};
|
||||
eprintln!("[drive {session}] type {text:?} -> {}", if ok { "ok" } else { "FAIL" });
|
||||
}
|
||||
Action::Click { rx, ry } => {
|
||||
let ok = match submit {
|
||||
Some(idx) => run_call(&cua, "click", &format!(
|
||||
r#"{{"pid":{pid},"window_id":{hwnd_addr},"element_index":{idx},"session":"{session}"}}"#)),
|
||||
None => {
|
||||
if let Some((x, y)) = client_rel_to_local_px(hwnd, rx, ry) {
|
||||
run_call(&cua, "click", &format!(
|
||||
r#"{{"pid":{pid},"window_id":{hwnd_addr},"x":{x},"y":{y},"session":"{session}"}}"#))
|
||||
} else { false }
|
||||
}
|
||||
};
|
||||
eprintln!("[drive {session}] click Add Record -> {}", if ok { "ok" } else { "FAIL" });
|
||||
|
||||
// Verify a record was actually COMMITTED: the "Records: N"
|
||||
// counter must increment. (Checking for the typed text
|
||||
// alone is fooled by it sitting in the field.) Only the
|
||||
// a11y corners expose the counter; GDI's is drawn pixels,
|
||||
// so it's verified visually (its grid grows on screen).
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
let t2 = run_call_out(&cua, "get_window_state", &ax);
|
||||
if let Some(n) = records_count(&t2) {
|
||||
let landed = n as i64 > seen_records;
|
||||
seen_records = n as i64;
|
||||
eprintln!("[verify {session}] {title} Records={n} -> {}",
|
||||
if landed { "COMMITTED ✓" } else { "FAIL (no new record)" });
|
||||
}
|
||||
}
|
||||
Action::DrawMaze => {
|
||||
// Pure coordinate drawing — NO element targeting, NO app
|
||||
// cooperation. Each maze segment is one straight
|
||||
// press-drag-release into the common REGION; cua-driver
|
||||
// PostMessages the drag where it can and pen-injects it
|
||||
// where the canvas (Chromium/WPF) drops posted mouse.
|
||||
let map = |mx: f64, my: f64| {
|
||||
let cfx = REGION[0] + mx * (REGION[2] - REGION[0]);
|
||||
let cfy = REGION[1] + my * (REGION[3] - REGION[1]);
|
||||
client_rel_to_local_px(hwnd, cfx, cfy)
|
||||
};
|
||||
let mut drawn = 0;
|
||||
for (x0, y0, x1, y1) in MAZE.iter() {
|
||||
if let (Some((fx, fy)), Some((tx, ty))) = (map(*x0, *y0), map(*x1, *y1)) {
|
||||
if run_call(&cua, "drag", &format!(
|
||||
r#"{{"pid":{pid},"window_id":{hwnd_addr},"from_x":{fx},"from_y":{fy},"to_x":{tx},"to_y":{ty},"dispatch":"background","session":"{session}"}}"#)) {
|
||||
drawn += 1;
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(120));
|
||||
}
|
||||
eprintln!("[draw {session}] maze: {drawn}/{} segments", MAZE.len());
|
||||
|
||||
// Read back by SCREENSHOT (WGC captures the occluded
|
||||
// window) and diff the rendered ink against the
|
||||
// rasterized reference maze — normalized into the same
|
||||
// REGION grid, pass only under threshold.
|
||||
thread::sleep(Duration::from_millis(400));
|
||||
let shot = std::env::temp_dir().join(format!("maze_{session}.png"));
|
||||
let _ = std::fs::remove_file(&shot);
|
||||
run_call_shot(&cua, &format!(
|
||||
r#"{{"pid":{pid},"window_id":{hwnd_addr},"capture_mode":"vision","max_image_dimension":4096,"session":"{session}"}}"#), &shot);
|
||||
match score_maze(hwnd, &shot) {
|
||||
Some(score) => eprintln!("[verify {session}] {title} maze score={score:.2} -> {}",
|
||||
if score >= MAZE_PASS { "DREW MAZE ✓" } else { "FAIL (lines don't match)" }),
|
||||
None => eprintln!("[verify {session}] {title} maze -> FAIL (no screenshot/region)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
eprintln!("[orch] ready — submit a record in the center MASTER; watch {} coloured cursors \
|
||||
drive the corner terminals in the background.", corners.len());
|
||||
|
||||
if std::env::args().any(|a| a == "--auto") {
|
||||
let s2 = senders.clone();
|
||||
thread::spawn(move || {
|
||||
for i in 1..=3 {
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
eprintln!("[orch] AUTO {i}: TYPE then CLICK");
|
||||
for tx in &s2 { let _ = tx.send(Action::Type { text: format!("SUBJECT-{i:03}") }); }
|
||||
thread::sleep(Duration::from_millis(1800));
|
||||
// SAVE/"Add Record" button center (matches the GDI/master layout)
|
||||
// for the pixel-driven GDI corner; a11y corners ignore rx/ry.
|
||||
for tx in &s2 { let _ = tx.send(Action::Click { rx: 0.22, ry: 0.495 }); }
|
||||
}
|
||||
// Finally: draw the same maze into every corner's drawing pad by
|
||||
// pure coordinate drags, then screenshot-diff each against the
|
||||
// reference. Proves canvas actuation with no a11y, no app help.
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
eprintln!("[orch] AUTO 4: DRAW MAZE (line tool) + screenshot diff");
|
||||
for tx in &s2 { let _ = tx.send(Action::DrawMaze); }
|
||||
});
|
||||
}
|
||||
|
||||
let reader = BufReader::new(master_out);
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let parts: Vec<&str> = line.trim().split('\t').collect();
|
||||
let action = match parts.as_slice() {
|
||||
["CLICK", rx, ry] => rx.parse::<f64>().ok().zip(ry.parse::<f64>().ok()).map(|(rx, ry)| Action::Click { rx, ry }),
|
||||
["TYPE", text] => Some(Action::Type { text: (*text).to_string() }),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(a) = action {
|
||||
eprintln!("[orch] user action: {a:?} -> driving {} corners", senders.len());
|
||||
for tx in &senders { let _ = tx.send(a.clone()); }
|
||||
}
|
||||
}
|
||||
|
||||
drop(senders);
|
||||
for h in handles { let _ = h.join(); }
|
||||
let _ = master.kill();
|
||||
for mut k in kids { let _ = k.kill(); }
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Action { Click { rx: f64, ry: f64 }, Type { text: String }, DrawMaze }
|
||||
|
||||
fn json_escape(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch { '"' => o.push_str("\\\""), '\\' => o.push_str("\\\\"), '\n' => o.push_str("\\n"),
|
||||
'\r' => {}, '\t' => o.push_str("\\t"), c => o.push(c) }
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// `cua-driver call <tool> <json>` (proxies to the daemon). True on success.
|
||||
fn run_call(cua: &PathBuf, tool: &str, json: &str) -> bool {
|
||||
Command::new(cua).arg("call").arg(tool).arg(json)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null())
|
||||
.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
/// Same, capturing stdout (for get_window_state readback).
|
||||
fn run_call_out(cua: &PathBuf, tool: &str, json: &str) -> String {
|
||||
Command::new(cua).arg("call").arg(tool).arg(json)
|
||||
.stderr(Stdio::null()).output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default()
|
||||
}
|
||||
/// get_window_state writing the screenshot PNG to `out` (WGC; captures even a
|
||||
/// fully occluded window). Used to read back what cua-driver drew.
|
||||
fn run_call_shot(cua: &PathBuf, json: &str, out: &std::path::Path) -> bool {
|
||||
Command::new(cua).arg("call").arg("get_window_state").arg(json)
|
||||
.arg("--screenshot-out-file").arg(out)
|
||||
.stdout(Stdio::null()).stderr(Stdio::null())
|
||||
.status().map(|s| s.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// DWM extended-frame size of `hwnd` — the pixel size of the WGC bitmap before
|
||||
/// get_window_state downscales it to fit `max_image_dimension`.
|
||||
fn window_frame_size(hwnd: HWND) -> Option<(i32, i32)> {
|
||||
unsafe {
|
||||
let mut d = RECT::default();
|
||||
if DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &mut d as *mut _ as *mut core::ffi::c_void, std::mem::size_of::<RECT>() as u32).is_err() {
|
||||
return None;
|
||||
}
|
||||
Some((d.right - d.left, d.bottom - d.top))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape-only F1 (0..1) of the maze cua-driver drew vs the reference maze.
|
||||
///
|
||||
/// Reads the ink from the window screenshot (cropped to the drawing REGION),
|
||||
/// then normalizes BOTH the drawn ink and the reference into a unit grid by
|
||||
/// their own bounding boxes — so the score reflects the *shape* of the lines,
|
||||
/// not where on the canvas they landed or at what scale (the user only wants
|
||||
/// the shape penalized). The screenshot is downscaled to fit
|
||||
/// `max_image_dimension`, so REGION (full-res bitmap px) is scaled by the
|
||||
/// PNG/frame ratio before cropping.
|
||||
fn score_maze(hwnd: HWND, png: &std::path::Path) -> Option<f64> {
|
||||
let img = image::open(png).ok()?.to_luma8();
|
||||
let (iw, ih) = (img.width() as i32, img.height() as i32);
|
||||
let (fw, fh) = window_frame_size(hwnd)?;
|
||||
if fw <= 0 || fh <= 0 { return None; }
|
||||
let (fx, fy) = (iw as f64 / fw as f64, ih as f64 / fh as f64);
|
||||
let (ax, ay) = client_rel_to_local_px(hwnd, REGION[0], REGION[1])?;
|
||||
let (bx, by) = client_rel_to_local_px(hwnd, REGION[2], REGION[3])?;
|
||||
let cx0 = (((ax.min(bx)) as f64 * fx).floor() as i32).max(0);
|
||||
let cy0 = (((ay.min(by)) as f64 * fy).floor() as i32).max(0);
|
||||
let cx1 = (((ax.max(bx)) as f64 * fx).ceil() as i32).min(iw - 1);
|
||||
let cy1 = (((ay.max(by)) as f64 * fy).ceil() as i32).min(ih - 1);
|
||||
if cx1 - cx0 < 12 || cy1 - cy0 < 12 { return None; }
|
||||
|
||||
// Drawn ink + its bounding box.
|
||||
let mut ink: Vec<(i32, i32)> = Vec::new();
|
||||
let (mut nx, mut ny, mut xx, mut xy) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for y in cy0..=cy1 {
|
||||
for x in cx0..=cx1 {
|
||||
if img.get_pixel(x as u32, y as u32).0[0] < 150 {
|
||||
ink.push((x, y));
|
||||
if x < nx { nx = x } if x > xx { xx = x }
|
||||
if y < ny { ny = y } if y > xy { xy = y }
|
||||
}
|
||||
}
|
||||
}
|
||||
const N: usize = 96;
|
||||
if ink.len() < 20 || xx <= nx || xy <= ny { return None; }
|
||||
let (bw, bh) = ((xx - nx) as f64, (xy - ny) as f64);
|
||||
let mut a = vec![false; N * N];
|
||||
for (x, y) in ink {
|
||||
let gx = (((x - nx) as f64 / bw) * (N as f64 - 1.0)).round() as usize;
|
||||
let gy = (((y - ny) as f64 / bh) * (N as f64 - 1.0)).round() as usize;
|
||||
a[gy.min(N - 1) * N + gx.min(N - 1)] = true;
|
||||
}
|
||||
|
||||
// Reference maze, normalized to ITS bounding box the same way.
|
||||
let (mut rnx, mut rny, mut rxx, mut rxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||||
for (x0, y0, x1, y1) in MAZE.iter() {
|
||||
for (px, py) in [(*x0, *y0), (*x1, *y1)] {
|
||||
if px < rnx { rnx = px } if px > rxx { rxx = px }
|
||||
if py < rny { rny = py } if py > rxy { rxy = py }
|
||||
}
|
||||
}
|
||||
let (rbw, rbh) = ((rxx - rnx).max(1e-6), (rxy - rny).max(1e-6));
|
||||
let mut b = vec![false; N * N];
|
||||
for (x0, y0, x1, y1) in MAZE.iter() {
|
||||
raster_line(&mut b, N, (x0 - rnx) / rbw, (y0 - rny) / rbh, (x1 - rnx) / rbw, (y1 - rny) / rbh);
|
||||
}
|
||||
Some(mask_f1(&a, &b, N, 2))
|
||||
}
|
||||
|
||||
/// Bresenham rasterize a normalized [0,1] segment into the N×N reference mask.
|
||||
fn raster_line(mask: &mut [bool], n: usize, x0: f64, y0: f64, x1: f64, y1: f64) {
|
||||
let s = n as f64 - 1.0;
|
||||
let (px1, py1) = ((x1 * s) as i32, (y1 * s) as i32);
|
||||
let (mut x, mut y) = ((x0 * s) as i32, (y0 * s) as i32);
|
||||
let (dx, dy) = ((px1 - x).abs(), -(py1 - y).abs());
|
||||
let (sx, sy) = (if x < px1 { 1 } else { -1 }, if y < py1 { 1 } else { -1 });
|
||||
let mut err = dx + dy;
|
||||
loop {
|
||||
if x >= 0 && y >= 0 && (x as usize) < n && (y as usize) < n { mask[y as usize * n + x as usize] = true; }
|
||||
if x == px1 && y == py1 { break; }
|
||||
let e2 = 2 * err;
|
||||
if e2 >= dy { err += dy; x += sx; }
|
||||
if e2 <= dx { err += dx; y += sy; }
|
||||
}
|
||||
}
|
||||
|
||||
/// Chebyshev dilation by radius `r`.
|
||||
fn dilate(m: &[bool], n: usize, r: i32) -> Vec<bool> {
|
||||
let mut o = vec![false; n * n];
|
||||
for y in 0..n as i32 {
|
||||
for x in 0..n as i32 {
|
||||
if !m[y as usize * n + x as usize] { continue; }
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx >= 0 && ny >= 0 && (nx as usize) < n && (ny as usize) < n { o[ny as usize * n + nx as usize] = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// F1 of two ink masks, each dilated by `r` so near-misses count as matches.
|
||||
fn mask_f1(a: &[bool], b: &[bool], n: usize, r: i32) -> f64 {
|
||||
let (da, db) = (dilate(a, n, r), dilate(b, n, r));
|
||||
let (mut ca, mut cb, mut ma, mut mb) = (0usize, 0usize, 0usize, 0usize);
|
||||
for i in 0..n * n {
|
||||
if a[i] { ca += 1; if db[i] { ma += 1; } }
|
||||
if b[i] { cb += 1; if da[i] { mb += 1; } }
|
||||
}
|
||||
if ca == 0 || cb == 0 { return 0.0; }
|
||||
let precision = ma as f64 / ca as f64; // drawn ink that is near the reference
|
||||
let recall = mb as f64 / cb as f64; // reference covered by drawn ink
|
||||
if precision + recall == 0.0 { 0.0 } else { 2.0 * precision * recall / (precision + recall) }
|
||||
}
|
||||
|
||||
/// First `[N]` element index on a tree line matching `pred`.
|
||||
fn find_idx(tree: &str, pred: impl Fn(&str) -> bool) -> Option<u64> {
|
||||
// The tree is one JSON line with literal "\n" separators between rows.
|
||||
for line in tree.split("\\n") {
|
||||
if !pred(line) { continue; }
|
||||
let st = line.find('[')? + 1;
|
||||
let en = line[st..].find(']')? + st;
|
||||
if let Ok(n) = line[st..en].trim().parse() { return Some(n); }
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse the "Records: N" counter from a window-state tree (status bar).
|
||||
fn records_count(tree: &str) -> Option<u32> {
|
||||
let pos = tree.find("Records:")?;
|
||||
let rest = &tree[pos + "Records:".len()..];
|
||||
let digits: String = rest.trim_start().chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
fn work_area() -> RECT {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{SystemParametersInfoW, SPI_GETWORKAREA, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS};
|
||||
let mut wa = RECT::default();
|
||||
unsafe { let _ = SystemParametersInfoW(SPI_GETWORKAREA, 0, Some(&mut wa as *mut _ as *mut core::ffi::c_void), SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0)); }
|
||||
if wa.right <= wa.left || wa.bottom <= wa.top {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSCREEN, SM_CYSCREEN};
|
||||
unsafe { wa = RECT { left: 0, top: 0, right: GetSystemMetrics(SM_CXSCREEN), bottom: GetSystemMetrics(SM_CYSCREEN) }; }
|
||||
}
|
||||
wa
|
||||
}
|
||||
|
||||
fn place(hwnd: HWND, x: i32, y: i32, w: i32, h: i32, activate: bool) {
|
||||
if hwnd.0.is_null() { return; }
|
||||
let flags = if activate { SWP_SHOWWINDOW } else { SWP_NOACTIVATE | SWP_SHOWWINDOW | SWP_NOZORDER };
|
||||
unsafe { let _ = SetWindowPos(hwnd, HWND_TOP, x, y, w, h, flags); }
|
||||
}
|
||||
|
||||
/// Client-relative (0..1) -> the click tool's window-local screenshot-pixel
|
||||
/// space (ClientToScreen minus the DWM extended-frame top-left + 1px inset).
|
||||
fn client_rel_to_local_px(hwnd: HWND, rx: f64, ry: f64) -> Option<(i32, i32)> {
|
||||
unsafe {
|
||||
let mut cr = RECT::default();
|
||||
GetClientRect(hwnd, &mut cr).ok()?;
|
||||
let mut pt = POINT { x: (rx * (cr.right - cr.left) as f64) as i32, y: (ry * (cr.bottom - cr.top) as f64) as i32 };
|
||||
let _ = ClientToScreen(hwnd, &mut pt);
|
||||
let mut dwm = RECT::default();
|
||||
if DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &mut dwm as *mut _ as *mut core::ffi::c_void, std::mem::size_of::<RECT>() as u32).is_err() {
|
||||
return Some((pt.x, pt.y));
|
||||
}
|
||||
Some((pt.x - dwm.left - 1, pt.y - dwm.top - 1))
|
||||
}
|
||||
}
|
||||
|
||||
// ── window discovery by title substring (case-insensitive) ────────────────────
|
||||
struct Finder { needle: String, hwnd: HWND, pid: u32 }
|
||||
unsafe extern "system" fn enum_cb(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let f = &mut *(lparam.0 as *mut Finder);
|
||||
if !IsWindowVisible(hwnd).as_bool() { return TRUE; }
|
||||
let mut buf = [0u16; 256];
|
||||
let n = GetWindowTextW(hwnd, &mut buf);
|
||||
if n > 0 {
|
||||
let title = String::from_utf16_lossy(&buf[..n as usize]);
|
||||
if title.to_lowercase().contains(&f.needle.to_lowercase()) {
|
||||
let mut pid = 0u32;
|
||||
GetWindowThreadProcessId(hwnd, Some(&mut pid));
|
||||
f.hwnd = hwnd; f.pid = pid;
|
||||
return BOOL(0);
|
||||
}
|
||||
}
|
||||
let _ = PWSTR::null();
|
||||
TRUE
|
||||
}
|
||||
fn find_window_by_title(needle: &str) -> Option<(HWND, u32)> {
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
loop {
|
||||
let mut f = Finder { needle: needle.to_string(), hwnd: HWND::default(), pid: 0 };
|
||||
unsafe { let _ = EnumWindows(Some(enum_cb), LPARAM(&mut f as *mut _ as isize)); }
|
||||
if !f.hwnd.0.is_null() { return Some((f.hwnd, f.pid)); }
|
||||
if Instant::now() > deadline { return None; }
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user