diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 397c2db..0d51192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ concurrency: env: COMMON_BUILD_PKGS: "clang ccache make pkg-config python3 libsdl2-dev libsdl2-ttf-dev libpixman-1-dev" MOTIF_BUILD_PKGS: "autoconf automake libtool bison flex gawk m4" + DIFFERENTIAL_PKGS: "imagemagick openssh-client python3-pil rsync xauth xvfb" jobs: # ---- Lint: formatting, newline, security, cppcheck ---- @@ -96,7 +97,9 @@ jobs: - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ${{ env.COMMON_BUILD_PKGS }} + sudo apt-get install -y --no-install-recommends \ + ${{ env.COMMON_BUILD_PKGS }} ${{ env.MOTIF_BUILD_PKGS }} \ + ${{ env.DIFFERENTIAL_PKGS }} - name: Cache upstream tarballs and extracted source/headers # Cache only the inputs that derive from sync-upstream-headers.py @@ -113,17 +116,17 @@ jobs: !build/upstream/**/*.o !build/upstream/**/*.d !build/upstream/motif-src - key: upstream-src-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} + key: upstream-src-v2-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} restore-keys: | - upstream-src-${{ runner.os }}- + upstream-src-v2-${{ runner.os }}- - name: Cache ccache uses: actions/cache@v5 with: path: ~/.cache/ccache - key: ccache-build-${{ runner.os }}-${{ github.sha }} + key: ccache-build-v2-${{ runner.os }}-${{ github.sha }} restore-keys: | - ccache-build-${{ runner.os }}- + ccache-build-v2-${{ runner.os }}- - name: Configure ccache run: | @@ -134,10 +137,17 @@ jobs: - name: Build libX11-compat.so run: make -j"$(nproc)" - - name: Run regression tests (make check) + - name: Run regression tests (make check-unit) + # check-unit is the sanitizer-friendly subset: in-tree binaries + # + symbol-coverage, no motif/violawww gates. The motif job below + # owns the heavy integration gates (check-link-motif, + # check-demos-motif, check-smoke, check-differential) with the + # MOTIF_DEMO_SKIP env this job does not set. env: - SDL_VIDEODRIVER: dummy - run: make check + UI_REPLAY_XVFB: --xvfb + UI_REPLAY_SCREENSHOT_COMMAND: import + UI_REPLAY_DISPLAY: 121 + run: make check-unit - name: Build bundled examples run: make examples -j"$(nproc)" @@ -155,7 +165,9 @@ jobs: - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ${{ env.COMMON_BUILD_PKGS }} + sudo apt-get install -y --no-install-recommends \ + ${{ env.COMMON_BUILD_PKGS }} ${{ env.MOTIF_BUILD_PKGS }} \ + ${{ env.DIFFERENTIAL_PKGS }} - name: Cache upstream tarballs and extracted source/headers # Cache only the inputs that derive from sync-upstream-headers.py @@ -172,9 +184,9 @@ jobs: !build/upstream/**/*.o !build/upstream/**/*.d !build/upstream/motif-src - key: upstream-src-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} + key: upstream-src-v2-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} restore-keys: | - upstream-src-${{ runner.os }}- + upstream-src-v2-${{ runner.os }}- - name: Cache ccache # Separate key from the release job so the debug @@ -182,9 +194,9 @@ jobs: uses: actions/cache@v5 with: path: ~/.cache/ccache - key: ccache-debug-${{ runner.os }}-${{ github.sha }} + key: ccache-debug-v2-${{ runner.os }}-${{ github.sha }} restore-keys: | - ccache-debug-${{ runner.os }}- + ccache-debug-v2-${{ runner.os }}- - name: Configure ccache run: | @@ -196,9 +208,15 @@ jobs: run: make CFLAGS_EXTRA=-DDEBUG_LIBX11_COMPAT -j"$(nproc)" - name: Run regression tests (debug) + # check-unit only: the umbrella check would recursively run + # check-demos-motif, which segfaults on programs/Tree/tree on + # this image. The motif job runs the full check with the + # MOTIF_DEMO_SKIP env that makes that demo opt-out. env: - SDL_VIDEODRIVER: dummy - run: make CFLAGS_EXTRA=-DDEBUG_LIBX11_COMPAT check + UI_REPLAY_XVFB: --xvfb + UI_REPLAY_SCREENSHOT_COMMAND: import + UI_REPLAY_DISPLAY: 121 + run: make CFLAGS_EXTRA=-DDEBUG_LIBX11_COMPAT check-unit - name: ccache stats run: ccache --show-stats @@ -213,7 +231,9 @@ jobs: - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends ${{ env.COMMON_BUILD_PKGS }} + sudo apt-get install -y --no-install-recommends \ + ${{ env.COMMON_BUILD_PKGS }} ${{ env.MOTIF_BUILD_PKGS }} \ + ${{ env.DIFFERENTIAL_PKGS }} - name: Cache upstream tarballs and extracted source/headers # Cache only the inputs that derive from sync-upstream-headers.py @@ -230,17 +250,17 @@ jobs: !build/upstream/**/*.o !build/upstream/**/*.d !build/upstream/motif-src - key: upstream-src-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} + key: upstream-src-v2-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} restore-keys: | - upstream-src-${{ runner.os }}- + upstream-src-v2-${{ runner.os }}- - name: Cache ccache uses: actions/cache@v5 with: path: ~/.cache/ccache - key: ccache-sanitize-${{ runner.os }}-${{ github.sha }} + key: ccache-sanitize-v2-${{ runner.os }}-${{ github.sha }} restore-keys: | - ccache-sanitize-${{ runner.os }}- + ccache-sanitize-v2-${{ runner.os }}- - name: Configure ccache # Sanitizer flag set defines the cache key partition for ccache, @@ -258,7 +278,9 @@ jobs: # services the .so as well. -fno-sanitize-recover=undefined # turns UBSan findings into hard failures. SAN_FLAGS: "-fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer -g -O1" - SDL_VIDEODRIVER: dummy + UI_REPLAY_XVFB: --xvfb + UI_REPLAY_SCREENSHOT_COMMAND: import + UI_REPLAY_DISPLAY: 121 # SDL2's dummy driver retains pools across XCloseDisplay, so # leak detection produces unrelated noise. Keep memory-error # detection on; revisit leaks separately. @@ -266,21 +288,24 @@ jobs: UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 run: | make CFLAGS_EXTRA="$SAN_FLAGS" LDFLAGS="$SAN_FLAGS" -j"$(nproc)" - make CFLAGS_EXTRA="$SAN_FLAGS" LDFLAGS="$SAN_FLAGS" check + # Run only the cheap unit-test subset under sanitizer. The + # umbrella `make check` would recursively trigger motif's + # autoconf which probes for Xutf8TextExtents via a link test + # that fails when SAN_FLAGS leak into the upstream configure + # ("Motif requires a UTF-8 capable libX11"). + make CFLAGS_EXTRA="$SAN_FLAGS" LDFLAGS="$SAN_FLAGS" check-unit - name: ccache stats run: ccache --show-stats - # ---- Motif integration and validation ---- + # ---- Motif, ViolaWWW integration and validation ---- # # Build the thentenaar/motif libXm and libMrm against the compat stack, # build every Motif demo program, and run the demo smoke checks # (validate-motif-demos.sh launches each demo briefly and asserts no - # fatal output / abnormal exit). This is the local equivalent of the - # `motif-differential` make target, which orchestrates the same flow - # plus an SSH-driven comparison against a system libX11 host; the - # remote half isn't reproducible in a stock GitHub runner so we run - # the compat-stack half here. + # fatal output / abnormal exit). ViolaWWW is built locally and, when a + # remote differential host is configured, Motif and ViolaWWW are + # screenshot-compared against system libX11 under Xvfb. motif: runs-on: ubuntu-24.04 env: @@ -297,7 +322,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - ${{ env.COMMON_BUILD_PKGS }} ${{ env.MOTIF_BUILD_PKGS }} + ${{ env.COMMON_BUILD_PKGS }} ${{ env.MOTIF_BUILD_PKGS }} \ + ${{ env.DIFFERENTIAL_PKGS }} - name: Cache upstream tarballs and extracted source/headers # Same cache as the other compile jobs use, scoped to @@ -311,9 +337,9 @@ jobs: !build/upstream/**/*.o !build/upstream/**/*.d !build/upstream/motif-src - key: upstream-src-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} + key: upstream-src-v2-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} restore-keys: | - upstream-src-${{ runner.os }}- + upstream-src-v2-${{ runner.os }}- - name: Cache Motif source clone and autoreconf output # Exact-match key only (no restore-keys fallback). The Motif @@ -330,9 +356,9 @@ jobs: uses: actions/cache@v5 with: path: ~/.cache/ccache - key: ccache-motif-${{ runner.os }}-${{ github.sha }} + key: ccache-motif-v2-${{ runner.os }}-${{ github.sha }} restore-keys: | - ccache-motif-${{ runner.os }}- + ccache-motif-v2-${{ runner.os }}- - name: Configure ccache run: | @@ -361,7 +387,7 @@ jobs: # crash log (uploaded by the next step on failure), skip so # the rest of the smoke set can keep gating. MOTIF_DEMO_SKIP: "programs/Tree/tree" - run: make motif-demos-check + run: make check-demos-motif - name: Upload Motif demo logs on failure # Captures every demo's per-run log so a future regression can be @@ -374,5 +400,128 @@ jobs: if-no-files-found: warn retention-days: 7 + - name: Build ViolaWWW against compat stack + id: violawww + # Build the bolknote/violawww browser against libx11-compat + + # libXt-compat + libXpm-compat + libXmu-compat + bundled Motif. + # This is a build-only differential gate: the source is cloned + # at the pinned commit in mk/violawww.mk and compiled. A clean + # build verifies the full Xt + Motif + Xext surface the demos + # alone don't cover (e.g. selection helpers, XmuConvertStandard, + # Xpm pixmap loaders). The full per-step log is written to + # build/violawww/build.log and uploaded on failure. + # + # !cancelled() runs this step even if check-demos-motif + # above failed, so the two differential gates report + # independently. A workflow cancellation still aborts. + if: ${{ !cancelled() }} + run: make violawww + + - name: Run Xvfb-backed replay smoke checks + # make check-smoke is intentionally usable without Xvfb/xdotool on a + # developer machine. GitHub-hosted runners are headless, so CI opts + # into Xvfb explicitly for deterministic screenshots while still using + # the in-process LIBX11_COMPAT_REPLAY input backend. ViolaWWW was built + # in the previous step, so clear its smoke prerequisite only when that + # build actually succeeded; otherwise the smoke would fail later with a + # missing vw binary and obscure the build failure. + if: ${{ !cancelled() && steps.violawww.outcome == 'success' }} + env: + UI_REPLAY_XVFB: --xvfb + UI_REPLAY_SCREENSHOT_COMMAND: import + UI_REPLAY_DISPLAY: 121 + VIOLAWWW_SMOKE_DEPS: "" + run: make check-smoke + + - name: Run Motif differential screenshots + id: motif-differential + # SSHes to a Linux/Xvfb host, builds the pinned Motif inputs + # against system libX11 and libx11-compat, captures screenshots + # for both paths, and compares visible output. Configure + # MOTIF_DIFF_REMOTE as a repository variable when CI can reach + # the host; otherwise the gate is skipped, leaving the ViolaWWW + # differential step below independently runnable. + if: ${{ !cancelled() && vars.MOTIF_DIFF_REMOTE != '' }} + env: + MOTIF_DIFF_REMOTE: ${{ vars.MOTIF_DIFF_REMOTE }} + MOTIF_DIFF_INSTALL_DEPS: ${{ vars.MOTIF_DIFF_INSTALL_DEPS || '0' }} + MOTIF_DIFF_JOBS: ${{ vars.MOTIF_DIFF_JOBS || '1' }} + MOTIF_DIFF_COMPARE_LOCATION: remote + MOTIF_DIFF_REPLAY: ${{ vars.MOTIF_DIFF_REPLAY || '0' }} + run: make check-differential-motif + + - name: Run ViolaWWW differential screenshots + id: violawww-differential + # Companion to the Motif step above. Runs only when ViolaWWW + # built successfully (otherwise the screenshot capture would + # exercise a stale or missing binary and produce misleading + # diffs) and when VIOLAWWW_DIFF_REMOTE is configured. Gated + # independently of motif-differential so each surfaces its own + # outcome and artifact-upload trigger. + if: ${{ !cancelled() && steps.violawww.outcome == 'success' && vars.VIOLAWWW_DIFF_REMOTE != '' }} + env: + VIOLAWWW_DIFF_REMOTE: ${{ vars.VIOLAWWW_DIFF_REMOTE }} + VIOLAWWW_DIFF_INSTALL_DEPS: ${{ vars.VIOLAWWW_DIFF_INSTALL_DEPS || '0' }} + VIOLAWWW_DIFF_JOBS: ${{ vars.VIOLAWWW_DIFF_JOBS || '1' }} + VIOLAWWW_DIFF_COMPARE_LOCATION: remote + run: make check-differential-violawww + + - name: Upload ViolaWWW build log on failure + # Upload only when this specific step failed. A bare + # `if: failure()` would also fire on an earlier failure (e.g. + # check-demos-motif) and attach a successful ViolaWWW log, + # which would be a misleading artifact. + if: ${{ steps.violawww.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: violawww-build-log + path: build/violawww/build.log + if-no-files-found: warn + retention-days: 7 + + - name: Upload UI smoke artifacts on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: ui-smoke + path: build/ui-smoke + if-no-files-found: warn + retention-days: 7 + + - name: Upload Motif differential artifacts on failure + if: ${{ steps.motif-differential.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: motif-differential + path: | + build/motif-differential/report.tsv + build/motif-differential/junit.xml + build/motif-differential/logs + build/motif-differential/diff + build/motif-differential/system + build/motif-differential/compat + if-no-files-found: warn + retention-days: 7 + + - name: Upload ViolaWWW differential artifacts on failure + if: ${{ steps.violawww-differential.outcome == 'failure' }} + uses: actions/upload-artifact@v7 + with: + name: violawww-differential + path: | + build/violawww-differential/report.tsv + build/violawww-differential/junit.xml + build/violawww-differential/logs + build/violawww-differential/diff + build/violawww-differential/system + build/violawww-differential/compat + if-no-files-found: warn + retention-days: 7 + - name: ccache stats + # Always log ccache stats so we can see whether earlier-step + # failures still produced cache activity. !cancelled() keeps + # this gated on workflow cancellation while running through + # both demo and violawww failures. + if: ${{ !cancelled() }} run: ccache --show-stats diff --git a/.gitignore b/.gitignore index 57a0953..fb497bc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ out-test/ *.d *.so __pycache__/ +externals/violawww/ diff --git a/Makefile b/Makefile index a6fe060..f365f2c 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ include mk/libxpm.mk include mk/xcompat-libs.mk include mk/pkgconfig.mk include mk/motif.mk +include mk/violawww.mk include mk/tests.mk include mk/examples.mk include mk/upstream-headers.mk diff --git a/README.md b/README.md index 009ee1f..be00333 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Clients link against it the same way they would link against the system `libX11. ## Examples +![ViolaWWW running through libx11-compat on macOS](assets/violawww.png) + `examples/` bundles real Xlib clients built against the local `libX11-compat.so`: ```sh @@ -38,10 +40,43 @@ build/examples/2048 The bundle covers a 2048 game, a paint demo, Conway's Game of Life, an analog clock, an interactive Mandelbrot viewer, a single-runner Processing-style showcase, an SDL-backed clipboard `TARGETS` probe, and the upstream X.Org `x11perf` benchmark. See [`docs/EXAMPLES.md`](docs/EXAMPLES.md) for the API each example exercises. +The screenshot above is from the larger ViolaWWW port described in [Larger Workloads Under Investigation](#larger-workloads-under-investigation). + +## Larger Workloads Under Investigation + +Two ports beyond the bundled demos are actively exercising `libx11-compat`. +Both are work-in-progress rather than ready for daily use, +but each surfaces gaps in coverage and edge cases that small examples never reach. + +- [Motif](https://en.wikipedia.org/wiki/Motif_(software)): upstream Motif and its demo suite build against the compatibility libraries. + Menu posting, pointer grabs, focus changes, and text rendering all work in the cases tested so far, + and the screenshot-based differential harness flags regressions against native X11/Motif. + Some widget paths still trigger fallback or layout artifacts that require further work. +- [ViolaWWW](https://en.wikipedia.org/wiki/ViolaWWW): the 1992-era Motif web browser builds and runs out of the consolidated `build/` tree, + loads HTTP pages over the network, + and renders inline XPM images through `libXpm-compat`. + HTTPS, complex modern HTML, and several interactive flows are known limitations of the application itself rather than the compatibility layer. + + ```sh + make violawww # build ViolaWWW (depends on motif) + build/violawww/source/src/vw/vw # launch the browser + make check-smoke-violawww # replay-driven smoke checks (scroll + Help menu) + make check-differential-violawww # screenshot diff vs system libX11 (needs remote host) + ``` + + The `check-*-smoke` targets need an X display, or `UI_REPLAY_XVFB=--xvfb` to drive an in-process Xvfb; + on macOS the browser launches against the host SDL backend without extra setup. + +The value of these targets is not that they replace a real X11/Motif install today, +but that they keep legacy Xlib/Motif code building and running on platforms where no X server is available while migration is in progress. + +Both ports rely on community input. +Concrete reproducers, screenshot diffs, and small fixes posted to [GitHub Issues](https://github.com/sysprog21/libx11-compat/issues) are the most effective way to push these workloads forward. +Specific gaps worth opening issues for include widget paths that misrender, Motif demos that crash or differ visibly from the native baseline, and ViolaWWW interactions (navigation, dialogs, image formats) that do not behave as the historical browser did. ## Coverage and Compatibility -The library exports 615 public Xlib symbols listed in [`tests/api-symbols.txt`](tests/api-symbols.txt), +The library exports 631 public Xlib symbols listed in [`tests/api-symbols.txt`](tests/api-symbols.txt), covering window, drawable, GC, pixmap, image, event, input, atom, property, color, font, cursor, and region subsystems for the cases that real Xlib clients exercise. Selection, property, and resource-manager support is partial; MIT-SHM is a thin wrapper over the regular image path; diff --git a/assets/violawww.png b/assets/violawww.png new file mode 100644 index 0000000..f2c42b1 Binary files /dev/null and b/assets/violawww.png differ diff --git a/compat/motif-patches/asan-safe-string-and-printer-lists.patch b/compat/motif-patches/asan-safe-string-and-printer-lists.patch new file mode 100644 index 0000000..3a1b810 --- /dev/null +++ b/compat/motif-patches/asan-safe-string-and-printer-lists.patch @@ -0,0 +1,44 @@ +diff --git a/demos/lib/Xmd/Print.c b/demos/lib/Xmd/Print.c +--- a/demos/lib/Xmd/Print.c ++++ b/demos/lib/Xmd/Print.c +@@ -604,11 +604,11 @@ process_printer_list(Widget w) + /* Terminate with 0 */ + buf[count++] = 0; + /* Make sure printer lists are big enough */ + pw -> print.printers = (char**) XtRealloc((char*) pw -> print.printers, +- sizeof(char*) * +- pw -> print.num_printers + 1); ++ sizeof(char*) * ++ (pw -> print.num_printers + 1)); + pw -> print.is_printer = + (Boolean*) XtRealloc((char*) pw -> print.is_printer, +- sizeof(Boolean) * pw -> print.num_printers + 1); ++ sizeof(Boolean) * (pw -> print.num_printers + 1)); + pw -> print.printers[pw -> print.num_printers] = XtNewString(buf); + pw -> print.is_printer[pw -> print.num_printers] = ! is_path; + pw -> print.num_printers++; +diff --git a/lib/Xm/XmExtUtil.c b/lib/Xm/XmExtUtil.c +--- a/lib/Xm/XmExtUtil.c ++++ b/lib/Xm/XmExtUtil.c +@@ -349,7 +349,7 @@ _XmGetMBStringFromXmString(XmString xmstr) + { + case XmSTRING_COMPONENT_TEXT: + case XmSTRING_COMPONENT_LOCALE_TEXT: +- length += strlen( newText ); ++ length += u_length; + break; + case XmSTRING_COMPONENT_SEPARATOR: + length += 1; +@@ -396,7 +396,11 @@ _XmGetMBStringFromXmString(XmString xmstr) + { + case XmSTRING_COMPONENT_TEXT: + case XmSTRING_COMPONENT_LOCALE_TEXT: +- strcat(text, newText); ++ { ++ size_t text_len = strlen(text); ++ memcpy(text + text_len, newText, u_length); ++ text[text_len + u_length] = '\0'; ++ } + break; + case XmSTRING_COMPONENT_SEPARATOR: + strcat(text, "\n"); diff --git a/compat/motif-patches/silence-iconv-cascade-warnings.patch b/compat/motif-patches/silence-iconv-cascade-warnings.patch new file mode 100644 index 0000000..c3b1f10 --- /dev/null +++ b/compat/motif-patches/silence-iconv-cascade-warnings.patch @@ -0,0 +1,117 @@ +diff --git a/lib/Xm/XmString.c b/lib/Xm/XmString.c +index aed38e37..5a03e807 100644 +--- a/lib/Xm/XmString.c ++++ b/lib/Xm/XmString.c +@@ -312,6 +312,14 @@ static int _get_generate_parse_table(XmParseTable *gen_table); + static const char *WCHAR_T = "WCHAR_T"; + static const XmStringTag TO_UTF8 = "UTF-8"; + ++/* Quiet variant of _Xmcsconv: returns NULL on iconv_open failure without ++ * emitting XmeWarning. Used by the XmStringParse cascade (tag → locale.tag ++ * → XmFALLBACK_CHARSET) where each non-final step is expected to fail and ++ * only the final aggregated failure warrants a user-visible warning. */ ++static char *_Xmcsconv_silent(const char *from, const char *to, ++ char *text, size_t bytes, size_t *len_out); ++ ++ + static struct __Xmlocale locale; + static XmStringTag *_tag_cache = NULL; + static size_t _cache_count = 0; +@@ -404,6 +412,86 @@ again: + return result; + } + ++/* See declaration comment near the top of this file. */ ++static char *_Xmcsconv_silent(const char *from, const char *to, ++ char *text, size_t bytes, size_t *len_out) ++{ ++ iconv_t ic; ++ char *inbuf, *outbuf, *result; ++ size_t insz, outsz, used, conv; ++ char msg[256]; ++#if defined(_LIBICONV_VERSION) ++ char *_to, *_from; ++#endif ++ ++ if (from == XmFONTLIST_DEFAULT_TAG || (from && !strcmp(from, XmFONTLIST_DEFAULT_TAG))) ++ from = locale.tag; ++ ++#if defined(_LIBICONV_VERSION) ++ _to = XtMalloc(strlen(to) + 9); ++ _from = XtMalloc(strlen(from) + 9); ++ sprintf(_to, "%s//IGNORE", to); ++ sprintf(_from, "%s//IGNORE", from); ++ to = _to; ++ from = _from; ++#endif ++ ++ if ((ic = iconv_open(to, from)) == (iconv_t)-1) { ++ /* Silent: caller will cascade or warn aggregate. */ ++ *len_out = 0; ++#if defined(_LIBICONV_VERSION) ++ XtFree(_to); ++ XtFree(_from); ++#endif ++ return NULL; ++ } ++ ++ insz = bytes; ++ inbuf = text; ++ outsz = 1 + (bytes << 2); ++ outbuf = XtMalloc(outsz); ++ result = outbuf; ++ ++silent_again: ++ if ((conv = iconv(ic, &inbuf, &insz, &outbuf, &outsz)) == (size_t)-1) { ++ switch (errno) { ++ case EINVAL: ++ break; ++ case E2BIG: ++ insz = bytes; ++ inbuf = text; ++ used = (size_t)(outbuf - result); ++ outsz += 2 + (outsz >> 2); ++ result = XtRealloc(result, outsz); ++ outbuf = result + used; ++ goto silent_again; ++ case EILSEQ: ++ XmeWarning(NULL, "Invalid byte sequence in character set conversion input"); ++ break; ++ default: ++ snprintf(msg, sizeof msg, "Error during charset conversion: %s", strerror(errno)); ++ XmeWarning(NULL, msg); ++ } ++ ++ iconv_close(ic); ++ XtFree(result); ++#if defined(_LIBICONV_VERSION) ++ XtFree(_to); ++ XtFree(_from); ++#endif ++ if (len_out) *len_out = 0; ++ return NULL; ++ } ++ ++ iconv_close(ic); ++#if defined(_LIBICONV_VERSION) ++ XtFree(_to); ++ XtFree(_from); ++#endif ++ if (len_out) *len_out = (size_t)(outbuf - result); ++ return result; ++} ++ + /** + * Create a new XmString + */ +@@ -5876,8 +5964,8 @@ static void parse_unmatched(XmString *result, char **ptr, + * So, if we can't convert based on the tag, fallback to the locale + * charset and further back to the fallback charset (Latin 1). + */ +- if (!(out = _Xmcsconv(tag, TO_UTF8, *ptr, length, &convsz))) { +- if (!(out = _Xmcsconv(locale.tag, TO_UTF8, *ptr, length, &convsz))) ++ if (!(out = _Xmcsconv_silent(tag, TO_UTF8, *ptr, length, &convsz))) { ++ if (!(out = _Xmcsconv_silent(locale.tag, TO_UTF8, *ptr, length, &convsz))) + out = _Xmcsconv(XmFALLBACK_CHARSET, TO_UTF8, *ptr, length, &convsz); + } + diff --git a/compat/violawww-patches/clear-before-shown-position-redraw.patch b/compat/violawww-patches/clear-before-shown-position-redraw.patch new file mode 100644 index 0000000..71c1e81 --- /dev/null +++ b/compat/violawww-patches/clear-before-shown-position-redraw.patch @@ -0,0 +1,16 @@ +--- a/src/viola/cl_txtDisp.c ++++ b/src/viola/cl_txtDisp.c +@@ -1090,6 +1090,13 @@ int help_txtDisp_shownPositionV(VObj* self, int newPosition) + return newPosition; + /* textFieldJumpToLine(self, destLine);*/ + GLPrepareObjColor(self); ++ /* ++ * Wheel scrolling and Motif scrollbar dragging both enter through ++ * shownPositionV. Clear the backing window before jump/redraw so stale ++ * glyphs from the old viewport are not left under the new text frame. ++ */ ++ if (GET_window(self)) ++ GLClearWindow(GET_window(self)); + tfed_jumpToOffsetLine(self, destLine); + + SET_shownPositionV(self, newPosition); diff --git a/compat/violawww-patches/clear-canvas-before-scroll-position.patch b/compat/violawww-patches/clear-canvas-before-scroll-position.patch new file mode 100644 index 0000000..0f93f5f --- /dev/null +++ b/compat/violawww-patches/clear-canvas-before-scroll-position.patch @@ -0,0 +1,59 @@ +--- a/src/vw/callbacks.c ++++ b/src/vw/callbacks.c +@@ -816,6 +816,17 @@ + (positionPercent * maximum / SBAR_MAGNITUDE), NULL); + } + ++static void clearDocViewBeforeScroll(DocViewInfo* dvi, VObj* violaObj) { ++ if (dvi && dvi->canvas && XtIsRealized(dvi->canvas)) { ++ XClearArea(XtDisplay(dvi->canvas), XtWindow(dvi->canvas), 0, 0, 0, 0, False); ++ } ++ if (violaObj && GET_window(violaObj)) { ++ XClearArea(display, GET_window(violaObj), 0, 0, 0, 0, False); ++ } else if (dvi && dvi->violaDocViewWindow) { ++ XClearArea(display, dvi->violaDocViewWindow, 0, 0, 0, 0, False); ++ } ++} ++ + void scrollBarDrag(Widget sbar, XtPointer clientData, XtPointer callData) { + XmScrollBarCallbackStruct* sbData = (XmScrollBarCallbackStruct*)callData; + VObj* violaObj = (VObj*)clientData; +@@ -826,8 +837,11 @@ + XtVaGetValues(sbar, XmNsliderSize, &sliderSize, XmNmaximum, &maximum, NULL); + percent = SBAR_MAGNITUDE * sbData->value / (maximum - sliderSize); + +- if (violaObj) ++ if (violaObj) { ++ clearDocViewBeforeScroll(mainViewInfo(), violaObj); + sendTokenMessageN1int(violaObj, STR_shownPositionV, percent); ++ sendMessage1(violaObj, "render"); ++ } + + calledDrag++; + } +@@ -879,8 +893,11 @@ + + XtVaGetValues(sbar, XmNsliderSize, &sliderSize, XmNmaximum, &maximum, NULL); + percent = SBAR_MAGNITUDE * sbData->value / (maximum - sliderSize); +- if (violaObj) ++ if (violaObj) { ++ clearDocViewBeforeScroll(mainViewInfo(), violaObj); + sendTokenMessageN1int(violaObj, STR_shownPositionV, percent); ++ sendMessage1(violaObj, "render"); ++ } + } + } + +@@ -940,10 +957,12 @@ + } + + /* Notify Viola about the position change */ ++ clearDocViewBeforeScroll(dvi, dvi->violaDocViewObj); + sendTokenMessageN1int(dvi->violaDocViewObj, STR_shownPositionV, percent); + + /* Update scrollbar position */ + XtVaSetValues(dvi->scrollBar, XmNvalue, newValue, NULL); ++ sendMessage1(dvi->violaDocViewObj, "render"); + } + + /* diff --git a/compat/violawww-patches/clear-text-row-before-draw.patch b/compat/violawww-patches/clear-text-row-before-draw.patch new file mode 100644 index 0000000..959a416 --- /dev/null +++ b/compat/violawww-patches/clear-text-row-before-draw.patch @@ -0,0 +1,25 @@ +diff --git a/src/viola/tfed.c b/src/viola/tfed.c +--- a/src/viola/tfed.c ++++ b/src/viola/tfed.c +@@ -4984,15 +4984,20 @@ + short prevFontID, prevVideo; + char* buffp; + int flags; + GC usegc = gc_fg; + int localYOffset = *yoffset; + ++ if (tf->isRenderAble) { ++ XClearArea(display, w, tf->xUL, localYOffset, tf->width, ++ currentp->maxFontHeight * currentp->breakc, False); ++ } ++ + if (tf->align == PANE_CONFIG_CENTER) { + segpx = (tf->xLR - tf->xUL - lineSegWidth(tf, currentp)) / 2; + } else if (tf->align == PANE_CONFIG_E2W) { + segpx = tf->xLR - tf->xUL - lineSegWidth(tf, currentp); + } else { + segpx = tf->xUL; + } + + item.delta = 0; + item.chars = buff; diff --git a/compat/violawww-patches/clear-viola-target-after-resize.patch b/compat/violawww-patches/clear-viola-target-after-resize.patch new file mode 100644 index 0000000..e7d8e45 --- /dev/null +++ b/compat/violawww-patches/clear-viola-target-after-resize.patch @@ -0,0 +1,24 @@ +--- a/src/vw/callbacks.c ++++ b/src/vw/callbacks.c +@@ -1077,7 +1077,20 @@ + if (event->type == ConfigureNotify) { + XConfigureEvent* xcep = (XConfigureEvent*)event; + +- XResizeWindow(XtDisplay(widget), (Window)clientData, (unsigned int)xcep->width, (unsigned int)xcep->height); ++ Display* dpy = XtDisplay(widget); ++ Window target = (Window)clientData; ++ ++ XResizeWindow(dpy, target, (unsigned int)xcep->width, (unsigned int)xcep->height); ++ XClearArea(dpy, target, 0, 0, 0, 0, True); ++ DocViewInfo* dvi = mainViewInfo(); ++ if (dvi && dvi->violaDocViewObj && ++ (GET_window(dvi->violaDocViewObj) == target || ++ dvi->violaDocViewWindow == target)) { ++ clearDocViewBeforeScroll(dvi, dvi->violaDocViewObj); ++ sendMessage1N4int(dvi->violaDocViewObj, "config", -1, -1, ++ xcep->width, xcep->height); ++ sendMessage1(dvi->violaDocViewObj, "render"); ++ } + } + } + diff --git a/compat/violawww-patches/fill-text-window-before-render.patch b/compat/violawww-patches/fill-text-window-before-render.patch new file mode 100644 index 0000000..ce9dfa8 --- /dev/null +++ b/compat/violawww-patches/fill-text-window-before-render.patch @@ -0,0 +1,10 @@ +--- a/src/viola/tfed.c ++++ b/src/viola/tfed.c +@@ -5522,6 +5522,7 @@ int renderTF(TFStruct* tf) + GLPrepareObjColor(tf->self); + + XClearWindow(display, TFWINDOW); ++ XFillRectangle(display, TFWINDOW, gc_bg, 0, 0, tf->width, tf->height); + + if (BDPixel == BGPixel) { + XSetForeground(display, gc_mesh, FGPixel); diff --git a/compat/violawww-patches/full-redraw-on-scroll-delta.patch b/compat/violawww-patches/full-redraw-on-scroll-delta.patch new file mode 100644 index 0000000..1455557 --- /dev/null +++ b/compat/violawww-patches/full-redraw-on-scroll-delta.patch @@ -0,0 +1,18 @@ +--- a/src/viola/tfed.c ++++ b/src/viola/tfed.c +@@ -6019,8 +6019,15 @@ + + if (offsetdir == 0) + return 0; +- else if (offsetdir > 0) ++ ++ /* ++ * The copy-scroll optimization leaves stale glyphs on backends where ++ * transparent text redraws are not preceded by a full clear. The ++ * caller has already moved offsetp/currentp; redraw from the model. ++ */ ++ return renderTF(tf); ++ if (offsetdir > 0) + linesToMove = offsetdir; + else + linesToMove = -offsetdir; diff --git a/compat/violawww-patches/gcc-portability.patch b/compat/violawww-patches/gcc-portability.patch new file mode 100644 index 0000000..4127665 --- /dev/null +++ b/compat/violawww-patches/gcc-portability.patch @@ -0,0 +1,19 @@ +diff --git a/src/viola/cl_generic.c b/src/viola/cl_generic.c +--- a/src/viola/cl_generic.c ++++ b/src/viola/cl_generic.c +@@ -57,6 +57,7 @@ + #include + #include + #include ++#include + + #include "../libWWW/HTParse.h" + #include "../libWWW/HTTP.h" +@@ -1240,6 +1241,7 @@ + packet2->info.s = saveString(""); + packet3->info.s = saveString(path); + lameLoopEsc: ++; + + char* anchor = HTParse(addr, relative, PARSE_ANCHOR); + packet4->info.s = (anchor ? saveString(anchor) : saveString("")); diff --git a/compat/violawww-patches/use-libxpm-compat.patch b/compat/violawww-patches/use-libxpm-compat.patch new file mode 100644 index 0000000..78fb399 --- /dev/null +++ b/compat/violawww-patches/use-libxpm-compat.patch @@ -0,0 +1,52 @@ +diff --git a/Makefile b/Makefile +index 65bfe86..7c3713b 100644 +--- a/Makefile ++++ b/Makefile +@@ -191,7 +191,7 @@ LAUNCHER_DIR = $(SRC_DIR)/launcher + + # Library targets + LIBWWW = $(LIBWWW_DIR)/libwww.a +-LIBXPM = $(LIBXPM_DIR)/libXpm.a ++LIBXPM = + LIBXPA = $(LIBXPA_DIR)/libXpa.a + LIBIMG = $(LIBIMG_DIR)/libIMG.a + LIBSTYLE = $(LIBSTYLE_DIR)/libstg.o +diff --git a/src/viola/glib_x.c b/src/viola/glib_x.c +index c634069..ed36f9c 100644 +--- a/src/viola/glib_x.c ++++ b/src/viola/glib_x.c +@@ -46,7 +46,7 @@ + #include + #include + +-#include "../libXPM/xpm.h" ++#include + + #define USE_XLOADIMAGE_PACKAGE 1 + +diff --git a/src/vw/catalog.c b/src/vw/catalog.c +index be1d058..6fbed18 100644 +--- a/src/vw/catalog.c ++++ b/src/vw/catalog.c +@@ -43,7 +43,7 @@ + + #include "../viola/cexec.h" + #include "../viola/misc.h" +-#include "../libXPM/xpm.h" ++#include + + /* Forward declarations */ + static void forceDeleteFolder(Folder* folder, Catalog* catalog); +diff --git a/src/vw/vw.c b/src/vw/vw.c +index d7bca86..9734348 100644 +--- a/src/vw/vw.c ++++ b/src/vw/vw.c +@@ -52,7 +52,7 @@ + #include + #endif + +-#include "../libXPM/xpm.h" ++#include + #include "../viola/ast.h" + #include "../viola/discovery.h" + #include "../viola/cexec.h" diff --git a/include/X11/extensions/XTest.h b/include/X11/extensions/XTest.h new file mode 100644 index 0000000..60ac031 --- /dev/null +++ b/include/X11/extensions/XTest.h @@ -0,0 +1,107 @@ +/* libx11-compat XTest extension public surface. + * + * Mirrors the API from libXtst (xorg). On real X11, XTest is implemented + * server-side and an external client uses it to inject input events that + * the server then distributes to every connected client. libx11-compat is + * single-process (no IPC), so XTest here injects directly into the + * calling process's SDL event queue, which is the same queue the local + * vw / Motif application drains. Functionally equivalent: the fake event + * is indistinguishable from a real user click on the way through + * convertEvent(). + */ + +#ifndef _XTEST_H_ +#define _XTEST_H_ + +#include +#include +#include + +/* Forward-declare XDevice as opaque. Callers wanting device-aware + * XTestFake* variants must include XInput's + * themselves. libx11-compat treats device-event entry points as stubs. + * Guard against the typedef colliding with XInput.h, which defines the + * same name. The xorgproto convention is to gate it on _XTYPEDEF_XDEVICE. + */ +#ifndef _XTYPEDEF_XDEVICE +#define _XTYPEDEF_XDEVICE +struct _XDevice; +typedef struct _XDevice XDevice; +#endif + +_XFUNCPROTOBEGIN + +extern Bool XTestQueryExtension(Display *display, + int *event_basep, + int *error_basep, + int *majorp, + int *minorp); + +extern Bool XTestCompareCursorWithWindow(Display *display, + Window window, + Cursor cursor); + +extern Bool XTestCompareCurrentCursorWithWindow(Display *display, + Window window); + +extern int XTestFakeKeyEvent(Display *display, + unsigned int keycode, + Bool is_press, + unsigned long delay); + +extern int XTestFakeButtonEvent(Display *display, + unsigned int button, + Bool is_press, + unsigned long delay); + +extern int XTestFakeMotionEvent(Display *display, + int screen_number, + int x, + int y, + unsigned long delay); + +extern int XTestFakeRelativeMotionEvent(Display *display, + int x, + int y, + unsigned long delay); + +extern int XTestFakeDeviceKeyEvent(Display *display, + XDevice *dev, + unsigned int keycode, + Bool is_press, + int *axes, + int n_axes, + unsigned long delay); + +extern int XTestFakeDeviceButtonEvent(Display *display, + XDevice *dev, + unsigned int button, + Bool is_press, + int *axes, + int n_axes, + unsigned long delay); + +extern int XTestFakeProximityEvent(Display *display, + XDevice *dev, + Bool in_prox, + int *axes, + int n_axes, + unsigned long delay); + +extern int XTestFakeDeviceMotionEvent(Display *display, + XDevice *dev, + Bool is_relative, + int first_axis, + int *axes, + int n_axes, + unsigned long delay); + +extern int XTestGrabControl(Display *display, Bool impervious); + +extern void XTestSetGContextOfGC(GC gc, GContext gid); +extern void XTestSetVisualIDOfVisual(Visual *visual, VisualID visualid); +extern Status XTestDiscard(Display *display); + +_XFUNCPROTOEND + +#endif /* _XTEST_H_ */ diff --git a/include/X11/extensions/Xrender.h b/include/X11/extensions/Xrender.h index 59a7388..09c31bf 100644 --- a/include/X11/extensions/Xrender.h +++ b/include/X11/extensions/Xrender.h @@ -7,6 +7,27 @@ typedef unsigned long Picture; typedef unsigned long GlyphSet; typedef unsigned long Glyph; typedef unsigned long PictFormat; +typedef int XFixed; + +/* XFixed is 16.16 fixed-point; representable range is approximately + * [-32768, +32768). A bare cast of an out-of-range double to int is + * undefined in C, which upstream Xrender's macro form invites. Clamp + * to the representable range so misbehaving clients cannot trigger UB + * inside the compat layer. */ +static inline XFixed _xCompatDoubleToFixed(double f) +{ + double scaled = f * 65536.0; + /* NaN compares false to every numeric, so the clamp below would let + * it fall through to a (XFixed) cast, which is also UB. */ + if (scaled != scaled) + return 0; + if (scaled >= 2147483647.0) + return (XFixed) 0x7FFFFFFF; + if (scaled <= -2147483648.0) + return (XFixed) (-0x7FFFFFFF - 1); + return (XFixed) scaled; +} +#define XDoubleToFixed(f) _xCompatDoubleToFixed((double) (f)) typedef struct { short red; @@ -34,6 +55,26 @@ typedef struct { unsigned short alpha; } XRenderColor; +typedef struct { + XFixed matrix[3][3]; +} XTransform; + +typedef struct { + int repeat; + Picture alpha_map; + int alpha_x_origin; + int alpha_y_origin; + int clip_x_origin; + int clip_y_origin; + Pixmap clip_mask; + Bool graphics_exposures; + int subwindow_mode; + int poly_edge; + int poly_mode; + Atom dither; + Bool component_alpha; +} XRenderPictureAttributes; + typedef struct { GlyphSet glyphset; _Xconst char *chars; @@ -79,6 +120,20 @@ typedef struct { #define PictStandardA4 3 #define PictStandardA1 4 +#define CPRepeat (1L << 0) +#define CPAlphaMap (1L << 1) +#define CPAlphaXOrigin (1L << 2) +#define CPAlphaYOrigin (1L << 3) +#define CPClipXOrigin (1L << 4) +#define CPClipYOrigin (1L << 5) +#define CPClipMask (1L << 6) +#define CPGraphicsExposure (1L << 7) +#define CPSubwindowMode (1L << 8) +#define CPPolyEdge (1L << 9) +#define CPPolyMode (1L << 10) +#define CPDither (1L << 11) +#define CPComponentAlpha (1L << 12) + extern Bool XRenderQueryExtension(Display *dpy, int *event_base_return, int *error_base_return); @@ -121,6 +176,9 @@ extern void XRenderFillRectangles(Display *dpy, _Xconst XRenderColor *color, _Xconst XRectangle *rectangles, int n_rects); +extern void XRenderSetPictureTransform(Display *dpy, + Picture picture, + XTransform *transform); extern GlyphSet XRenderCreateGlyphSet(Display *dpy, _Xconst XRenderPictFormat *format); extern void XRenderAddGlyphs(Display *dpy, diff --git a/include/X11/extensions/xtestconst.h b/include/X11/extensions/xtestconst.h new file mode 100644 index 0000000..afd6871 --- /dev/null +++ b/include/X11/extensions/xtestconst.h @@ -0,0 +1,19 @@ +/* XTest protocol constants — kept here so libx11-compat consumers can + * include without depending on a host + * xorgproto / libXtst dev install. The pinned upstream tarball also + * provides this file (synced into build/upstream/include/) but the + * public copy under include/ is what shipped headers reference. + * + * Copyright 1992, 1998 The Open Group. SPDX-License-Identifier: MIT + */ +#ifndef _XTEST_CONST_H_ +#define _XTEST_CONST_H_ + +#define XTestNumberEvents 0 +#define XTestNumberErrors 0 +#define XTestCurrentCursor ((Cursor) 1) +#define XTestMajorVersion 2 +#define XTestMinorVersion 2 +#define XTestExtensionName "XTEST" + +#endif diff --git a/mk/common.mk b/mk/common.mk index 53c8fb7..271ddab 100644 --- a/mk/common.mk +++ b/mk/common.mk @@ -38,7 +38,7 @@ $(OUT): $(OUT)/%.o: %.c | $(OUT) @mkdir -p $(dir $@) @echo " CC $<" - $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(CFLAGS_EXTRA) \ + $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(STRICT_CFLAGS) $(CFLAGS_EXTRA) \ -MMD -MP -MF $(@:.o=.d) -MT $@ -MT $(@:.o=.d) -c $< -o $@ # Upstream sources staged under $(OUT)/upstream/src/ live next to their diff --git a/mk/config.mk b/mk/config.mk index 0cc6ec7..57db21e 100644 --- a/mk/config.mk +++ b/mk/config.mk @@ -33,6 +33,15 @@ CPPFLAGS += -Iinclude -Isrc \ $(SDL2_CFLAGS) $(SDL2_TTF_CFLAGS) $(PIXMAN_CFLAGS) \ -DNARROWPROTO -DXTHREADS -D_GNU_SOURCE CFLAGS += -std=c99 -Wall -Wextra -Wno-unused-parameter -fPIC +# Opt-in strict mode: STRICT=1 turns warnings into errors so CI surfaces +# new diagnostics at PR time. STRICT_CFLAGS is applied only to first- +# party objects via mk/common.mk's compile rule; upstream-derived libXt +# and libXpm sources have their own warning footprint we do not own, +# and the libXt typecast warnings would otherwise block the build. +STRICT_CFLAGS := +ifeq ($(STRICT),1) + STRICT_CFLAGS += -Werror +endif SDL_COMPAT_LIBS := -L$(abspath $(OUT)) -lSDL2-x11compat \ -lSDL2_ttf-x11compat LDLIBS += $(SDL_COMPAT_LIBS) $(PIXMAN_LIBS) -lm -pthread \ diff --git a/mk/motif.mk b/mk/motif.mk index 3146b74..7326895 100644 --- a/mk/motif.mk +++ b/mk/motif.mk @@ -2,7 +2,18 @@ MOTIF_COMMIT := 11cd50b42991e8f11a13522fd820d736cc2f7fcf MOTIF_URL := https://github.com/thentenaar/motif MOTIF_SRC_DIR := $(OUT)/upstream/motif-src MOTIF_SRC_STAMP := $(MOTIF_SRC_DIR)/.source-stamp -MOTIF_PATCHES := $(wildcard compat/motif-patches/*.patch) +MOTIF_PATCHES := $(sort $(wildcard compat/motif-patches/*.patch)) +# Snapshot the patch list so removals (not just edits) bump an mtime. +# $(MOTIF_PATCHES) alone only re-triggers on edits to files that still +# exist, so dropping a .patch would otherwise leave the patched tree +# stale. Rewrites only when the sorted list changes. +MOTIF_PATCH_LIST_FILE := $(OUT)/upstream/.motif-patch-list +$(shell mkdir -p $(dir $(MOTIF_PATCH_LIST_FILE)); \ + new='$(sort $(notdir $(wildcard compat/motif-patches/*.patch)))'; \ + old=$$(cat $(MOTIF_PATCH_LIST_FILE) 2>/dev/null || true); \ + if [ "$$new" != "$$old" ]; then \ + printf '%s\n' "$$new" > $(MOTIF_PATCH_LIST_FILE); \ + fi) MOTIF_AUTOGEN_STAMP := $(MOTIF_SRC_DIR)/.autogen-stamp MOTIF_AUTOGEN_LOG := $(abspath $(MOTIF_SRC_DIR))/autoreconf.log MOTIF_BUILD_DIR := $(OUT)/motif @@ -15,6 +26,8 @@ MOTIF_DEMOS_CONFIG_LOG := $(abspath $(MOTIF_DEMOS_BUILD_DIR))/configure.log MOTIF_DEMOS_BUILD_STAMP := $(MOTIF_DEMOS_BUILD_DIR)/.build-stamp MOTIF_LIBXM := $(OUT)/libXm.so MOTIF_LIBMRM := $(OUT)/libMrm.so +MOTIF_TEST_BINS := $(OUT)/tests/test-motif-link \ + $(OUT)/tests/test-motif-resources MOTIF_YACC ?= $(shell if [ -x /opt/homebrew/opt/bison/bin/bison ]; then printf '%s\n' '/opt/homebrew/opt/bison/bin/bison -y'; else printf '%s\n' yacc; fi) MOTIF_CFLAGS ?= -g -O0 MOTIF_LIBS ?= -lm @@ -62,14 +75,21 @@ MOTIF_DEMOS_CONFIGURE_FLAGS := \ $(MOTIF_COMMON_CONFIGURE_FLAGS) \ --enable-demos -$(MOTIF_SRC_STAMP): +## Re-trigger on makefile edits, patch edits, or patch additions/removals. +## $(MOTIF_PATCH_LIST_FILE) is rewritten by the $(shell) snapshot above +## whenever the sorted list of compat/motif-patches/*.patch changes, so +## naming it here covers the deletion case that $(MOTIF_PATCHES) misses. +$(MOTIF_SRC_STAMP): mk/motif.mk $(MOTIF_PATCHES) $(MOTIF_PATCH_LIST_FILE) @echo " GIT $(MOTIF_URL)" $(Q)mkdir -p $(dir $(MOTIF_SRC_DIR)) $(Q)test -d $(MOTIF_SRC_DIR)/.git || \ git clone $(MOTIF_GIT_Q) $(MOTIF_URL) $(MOTIF_SRC_DIR) - $(Q)cd $(MOTIF_SRC_DIR) && git checkout $(MOTIF_GIT_Q) --detach $(MOTIF_COMMIT) - $(Q)for patch in $(abspath $(MOTIF_PATCHES)); do \ - cd $(MOTIF_SRC_DIR) && \ + $(Q)cd $(MOTIF_SRC_DIR) && \ + git checkout $(MOTIF_GIT_Q) --detach $(MOTIF_COMMIT) && \ + git reset --hard $(MOTIF_GIT_Q) $(MOTIF_COMMIT) >/dev/null && \ + git clean $(MOTIF_GIT_Q) -fdx >/dev/null + $(Q)set -e; for patch in $(abspath $(MOTIF_PATCHES)); do \ + cd $(abspath $(MOTIF_SRC_DIR)); \ if git apply --check "$$patch"; then \ git apply "$$patch"; \ elif git apply --reverse --check "$$patch"; then \ @@ -184,27 +204,52 @@ endif echo " STAGE no libMrm.5.dylib or libMrm.so.5 found" >&2; exit 1; \ fi -.PHONY: motif motif-demos +$(OUT)/tests/test-motif-%: tests/test-motif-%.c $(MOTIF_LIBXM) \ + $(MOTIF_LIBMRM) $(LIBXT_TARGET) $(TARGET) + @mkdir -p $(dir $@) + @echo " CC $<" + $(Q)$(CC) $(CPPFLAGS) $(LIBXT_CPPFLAGS) -I$(MOTIF_BUILD_DIR)/lib \ + -I$(MOTIF_SRC_DIR)/lib $(CFLAGS) $(CFLAGS_EXTRA) $< \ + $(MOTIF_LIBXM) $(MOTIF_LIBMRM) $(LIBXT_TARGET) $(TARGET) \ + $(LDLIBS) $(MOTIF_LIBS) $(LIBXT_TEST_LDFLAGS) $(TEST_LDFLAGS) \ + -o $@ + +.PHONY: motif motif-demos check-link-motif ## Build thentenaar/motif libXm and libMrm against the compatibility stack motif: $(MOTIF_LIBXM) $(MOTIF_LIBMRM) ## Build thentenaar/motif demos for differential tests against host libXm/libMrm motif-demos: $(MOTIF_DEMOS_BUILD_STAMP) +## Run in-process Motif/Xt behavior checks against libx11-compat +check-link-motif: $(MOTIF_TEST_BINS) + @set -e; for test_bin in $(MOTIF_TEST_BINS); do \ + printf "$(BLUE)RUN$(RESET) %s\n" "$$test_bin"; \ + $(motif_runtime_env) SDL_VIDEODRIVER=dummy $$test_bin; \ + done + +UI_REPLAY_DISPLAY ?= 121 +UI_REPLAY_GEOMETRY ?= 1280x1024x24 +UI_REPLAY_SCREENSHOT_COMMAND ?= auto +UI_REPLAY_XVFB ?= +UI_SMOKE_OUT_ROOT ?= $(OUT)/ui-smoke + MOTIF_DIFF_REMOTE ?= node11 MOTIF_DIFF_REMOTE_ROOT ?= /tmp/libx11-compat-motif-differential MOTIF_DIFF_DISPLAY ?= 119 MOTIF_DIFF_JOBS ?= 1 MOTIF_DIFF_FILTER ?= MOTIF_DIFF_INSTALL_DEPS ?= 0 -MOTIF_DIFF_MAE_THRESHOLD ?= 0.08 -MOTIF_DIFF_CHANGED_THRESHOLD ?= 0.35 +MOTIF_DIFF_MAE_THRESHOLD ?= 0.04 +MOTIF_DIFF_CHANGED_THRESHOLD ?= 0.20 MOTIF_DIFF_SECONDS ?= 3 MOTIF_DIFF_GEOMETRY ?= 1280x1024x24 MOTIF_DIFF_TOP ?= 12 MOTIF_DIFF_COMPARE_LOCATION ?= remote MOTIF_DIFF_OUT_ROOT ?= $(OUT)/motif-differential MOTIF_DIFF_REPRESENTATIVE_FILTER ?= +MOTIF_DIFF_REPLAY ?= 0 +DIFFERENTIAL_REMOTE ?= 0 motif_diff_env = \ MOTIF_DIFF_REMOTE='$(MOTIF_DIFF_REMOTE)' \ @@ -219,25 +264,88 @@ motif_diff_env = \ MOTIF_DIFF_TOP='$(MOTIF_DIFF_TOP)' \ MOTIF_DIFF_COMPARE_LOCATION='$(MOTIF_DIFF_COMPARE_LOCATION)' \ MOTIF_DIFF_OUT_ROOT='$(abspath $(MOTIF_DIFF_OUT_ROOT))' \ - MOTIF_DIFF_REPRESENTATIVE_FILTER='$(MOTIF_DIFF_REPRESENTATIVE_FILTER)' + MOTIF_DIFF_REPRESENTATIVE_FILTER='$(MOTIF_DIFF_REPRESENTATIVE_FILTER)' \ + MOTIF_DIFF_REPLAY='$(MOTIF_DIFF_REPLAY)' + +motif_ui_replay_env = \ + --env DYLD_LIBRARY_PATH=$(abspath $(MOTIF_DEMOS_BUILD_DIR))/lib/Xm/.libs:$(abspath $(MOTIF_DEMOS_BUILD_DIR))/lib/Mrm/.libs:$(abspath $(MOTIF_DEMOS_BUILD_DIR))/clients/uil/.libs:$(abspath $(OUT))$${DYLD_LIBRARY_PATH:+:$$DYLD_LIBRARY_PATH} \ + --env LD_LIBRARY_PATH=$(abspath $(MOTIF_DEMOS_BUILD_DIR))/lib/Xm/.libs:$(abspath $(MOTIF_DEMOS_BUILD_DIR))/lib/Mrm/.libs:$(abspath $(MOTIF_DEMOS_BUILD_DIR))/clients/uil/.libs:$(abspath $(OUT))$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \ + --env LIBX11_COMPAT_FONT_DIR=$(abspath $(OUT))/../fonts -.PHONY: motif-differential motif-differential-full motif-demos-check motif-demos-screenshots +.PHONY: check-differential-motif check-differential-motif-full check-demos-motif motif-demos-screenshots check-smoke-motif FORCE ## Compare representative Motif demo screenshots for system libX11 vs libx11-compat on node11 -motif-differential: +check-differential-motif: $(Q)$(motif_diff_env) $(PYTHON) scripts/run-motif-differential-tests.py \ --mode representative \ + $(if $(filter 1 yes true,$(MOTIF_DIFF_REPLAY)),--replay-smoke) \ $(if $(filter 1 yes true,$(MOTIF_DIFF_INSTALL_DEPS)),--install-deps) ## Compare all Motif demo screenshots for system libX11 vs libx11-compat on node11 -motif-differential-full: +check-differential-motif-full: $(Q)$(motif_diff_env) $(PYTHON) scripts/run-motif-differential-tests.py \ --mode full \ + $(if $(filter 1 yes true,$(MOTIF_DIFF_REPLAY)),--replay-smoke) \ $(if $(filter 1 yes true,$(MOTIF_DIFF_INSTALL_DEPS)),--install-deps) ## Run Motif demo process smoke checks -motif-demos-check: $(MOTIF_DEMOS_BUILD_STAMP) +check-demos-motif: $(MOTIF_DEMOS_BUILD_STAMP) $(Q)scripts/validate-motif-demos.sh $(MOTIF_DEMOS_BUILD_DIR) $(OUT) ## Capture screenshots for Motif demos built against libx11-compat motif-demos-screenshots: $(MOTIF_DEMOS_BUILD_STAMP) $(Q)scripts/capture-motif-demo-screenshots.sh $(MOTIF_DEMOS_BUILD_DIR) $(OUT) + +## Run replay-based local Motif UI smoke checks against libx11-compat +check-smoke-motif: $(UI_SMOKE_OUT_ROOT)/motif-fileview-done/.stamp $(UI_SMOKE_OUT_ROOT)/motif-wsm-labels/.stamp + +# Pin fileview's main window to a deterministic origin so the +# in-process snapshot helper and screenshot region line up. Motif +# applies the -geometry hint to the main shell; the language-selection +# dialog may still center per its own resources. +MOTIF_FILEVIEW_GEOMETRY ?= 480x260+0+0 + +$(UI_SMOKE_OUT_ROOT)/motif-fileview-done/.stamp: FORCE $(MOTIF_DEMOS_BUILD_STAMP) + $(Q)$(PYTHON) scripts/run-ui-replay.py \ + --name motif-fileview-done \ + --app $(abspath $(MOTIF_DEMOS_BUILD_DIR))/demos/programs/fileview/fileview \ + --app-arg=-geometry --app-arg=$(MOTIF_FILEVIEW_GEOMETRY) \ + --workdir $(abspath $(MOTIF_DEMOS_BUILD_DIR))/demos/programs/fileview \ + --replay tests/ui/replays/motif-fileview-done.replay \ + --out-root $(abspath $(UI_SMOKE_OUT_ROOT))/motif-fileview-done \ + --display $(UI_REPLAY_DISPLAY) \ + --geometry $(UI_REPLAY_GEOMETRY) \ + --screenshot-command $(UI_REPLAY_SCREENSHOT_COMMAND) \ + $(UI_REPLAY_XVFB) \ + $(motif_ui_replay_env) \ + --env XAPPLRESDIR=$(abspath $(MOTIF_SRC_DIR))/demos/programs/fileview \ + --env XFILESEARCHPATH=$(abspath $(MOTIF_SRC_DIR))/demos/programs/fileview/%N.ad:$(abspath $(MOTIF_SRC_DIR))/demos/programs/fileview/%N + $(Q)touch $@ + +$(UI_SMOKE_OUT_ROOT)/motif-wsm-labels/.stamp: FORCE $(MOTIF_DEMOS_BUILD_STAMP) + $(Q)mkdir -p $(abspath $(UI_SMOKE_OUT_ROOT))/motif-wsm-home + $(Q)printf '%s\n' \ + 'wsm_WSM.WSM.0.linked:True' \ + 'wsm_WSM.WSM.0.allWorkspaces:True' \ + 'wsm_WSM.WSM.0.linkedRoom.hidden:0' \ + 'saveAsShell_WSM*allWorkspaces:True' \ + 'configureShell_WSM*allWorkspaces:True' \ + 'nameShell_WSM*allWorkspaces: True' \ + 'backgroundShell_WSM*allWorkspaces:True' \ + 'deleteShell_WSM*allWorkspaces:True' \ + 'occupyShell_WSM*allWorkspaces:True' \ + > $(abspath $(UI_SMOKE_OUT_ROOT))/motif-wsm-home/.wsmdb + $(Q)$(PYTHON) scripts/run-ui-replay.py \ + --name motif-wsm-labels \ + --app $(abspath $(MOTIF_DEMOS_BUILD_DIR))/demos/programs/workspace/wsm \ + --workdir $(abspath $(MOTIF_DEMOS_BUILD_DIR))/demos/programs/workspace \ + --replay tests/ui/replays/motif-wsm-labels.replay \ + --out-root $(abspath $(UI_SMOKE_OUT_ROOT))/motif-wsm-labels \ + --display $(UI_REPLAY_DISPLAY) \ + --geometry $(UI_REPLAY_GEOMETRY) \ + --screenshot-command $(UI_REPLAY_SCREENSHOT_COMMAND) \ + $(UI_REPLAY_XVFB) \ + $(motif_ui_replay_env) \ + --env HOME=$(abspath $(UI_SMOKE_OUT_ROOT))/motif-wsm-home \ + --env XAPPLRESDIR=$(abspath $(MOTIF_SRC_DIR))/demos/programs/workspace \ + --env XFILESEARCHPATH=$(abspath $(MOTIF_SRC_DIR))/demos/programs/workspace/%N.ad:$(abspath $(MOTIF_SRC_DIR))/demos/programs/workspace/%N + $(Q)touch $@ diff --git a/mk/tests.mk b/mk/tests.mk index 08d72fe..180e609 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -3,13 +3,20 @@ CHECK_BINS := $(OUT)/tests/check $(OUT)/tests/symbol-coverage \ $(OUT)/tests/test-libxt-resources \ $(OUT)/tests/test-xmu-link \ $(OUT)/tests/test-xinerama-link \ - $(OUT)/tests/test-libxpm-link + $(OUT)/tests/test-libxpm-link \ + $(OUT)/tests/test-xtest BENCH_BINS := $(OUT)/tests/bench-paths -.PHONY: check symbol-coverage api-symbol-coverage bench-paths +.PHONY: check check-unit check-differential symbol-coverage api-symbol-coverage bench-paths -## Build and run the regression test suite -check: $(CHECK_BINS) +## Run only the in-tree binary regression tests + api-symbol coverage. +## This is the cheap, sanitizer-friendly subset: no motif autoconf, no +## ViolaWWW build, no replay-driven UI smoke. Use this from CI sanitize +## jobs and from any caller that cannot afford a full motif build under +## CFLAGS_EXTRA flags (e.g. SAN_FLAGS), since the motif autoconf script +## probes for Xutf8TextExtents via a link test that fails when the wrong +## flag set leaks into the upstream configure. +check-unit: $(CHECK_BINS) @set -e; for test_bin in $(CHECK_BINS); do \ printf "$(BLUE)RUN$(RESET) %s\n" "$$test_bin"; \ SDL_VIDEODRIVER=dummy $$test_bin; \ @@ -17,6 +24,24 @@ check: $(CHECK_BINS) @printf "$(BLUE)RUN$(RESET) tests/check-api-symbols.py\n" $(Q)$(PYTHON) tests/check-api-symbols.py $(TARGET) tests/api-symbols.txt +## Full local regression suite: unit tests, motif link/demos gates, the +## replay smoke tier, and the SSH-backed differential screenshots. The +## sub-targets are invoked via $(MAKE) so progress markers stay ordered +## under -jN; failing prereqs surface inside the recipe rather than at +## dependency resolution. +check: check-unit + @printf "$(BLUE)RUN$(RESET) check-link-motif\n" + $(Q)$(MAKE) --no-print-directory check-link-motif + @printf "$(BLUE)RUN$(RESET) check-demos-motif\n" + $(Q)$(MAKE) --no-print-directory check-demos-motif + @printf "$(BLUE)RUN$(RESET) check-smoke\n" + $(Q)$(MAKE) --no-print-directory check-smoke + @printf "$(BLUE)RUN$(RESET) check-differential\n" + $(Q)$(MAKE) --no-print-directory check-differential + +## Run all system-libX11-vs-libx11-compat differential checks +check-differential: check-differential-motif check-differential-violawww + ## Run exported-symbol coverage checks symbol-coverage: $(OUT)/tests/symbol-coverage api-symbol-coverage SDL_VIDEODRIVER=dummy $(OUT)/tests/symbol-coverage diff --git a/mk/violawww.mk b/mk/violawww.mk new file mode 100644 index 0000000..d77aca5 --- /dev/null +++ b/mk/violawww.mk @@ -0,0 +1,235 @@ +VIOLAWWW_URL := https://github.com/bolknote/violawww +VIOLAWWW_REVISION ?= 9d2a4f8c3c26ad02196e87b1b4e2588946725e0f +VIOLAWWW_CLONE_FLAGS ?= --filter=blob:none --no-checkout +VIOLAWWW_DIR := $(OUT)/upstream/violawww-src +VIOLAWWW_SOURCE_STAMP := $(VIOLAWWW_DIR)/.source-stamp +VIOLAWWW_BUILD_DIR := $(OUT)/violawww +VIOLAWWW_WORK_DIR := $(VIOLAWWW_BUILD_DIR)/source +VIOLAWWW_VW := $(VIOLAWWW_WORK_DIR)/src/vw/vw +VIOLAWWW_VIOLA := $(VIOLAWWW_WORK_DIR)/src/viola/viola +VIOLAWWW_MOTIF_PREFIX := $(VIOLAWWW_BUILD_DIR)/motif-prefix +VIOLAWWW_MOTIF_INCLUDE := $(VIOLAWWW_MOTIF_PREFIX)/include +VIOLAWWW_MOTIF_INCLUDE_STAMP := $(VIOLAWWW_MOTIF_INCLUDE)/.stamp +VIOLAWWW_LOG := $(abspath $(VIOLAWWW_BUILD_DIR))/build.log +VIOLAWWW_PATCHES := $(sort $(wildcard compat/violawww-patches/*.patch)) + +VIOLAWWW_PLATFORM_CFLAGS := +ifeq ($(UNAME_S),Darwin) + VIOLAWWW_PLATFORM_CFLAGS += -DVIOLA_DARWIN +endif +ifeq ($(UNAME_S),Linux) + VIOLAWWW_PLATFORM_CFLAGS += -DVIOLA_LINUX +endif + +VIOLAWWW_CFLAGS ?= -O0 -g +VIOLAWWW_COMMON_CFLAGS := \ + $(VIOLAWWW_CFLAGS) \ + -std=gnu17 \ + -funsigned-char \ + -DVIOLA_LIBX11_COMPAT \ + $(VIOLAWWW_PLATFORM_CFLAGS) \ + -DNO_ALLOCA \ + -Wno-everything + +VIOLAWWW_X11_CFLAGS := \ + -I$(abspath include) \ + -I$(abspath $(OUT)/upstream/include) \ + -I$(abspath include/libxt-build) + +VIOLAWWW_X11_LIBS := \ + -L$(abspath $(OUT)) \ + -lXext-compat \ + -lXmu-compat \ + -lXt-compat \ + -lXpm-compat \ + -lX11-compat + +VIOLAWWW_LDFLAGS := -L$(abspath $(OUT)) +ifeq ($(UNAME_S),Linux) + VIOLAWWW_LDFLAGS += -Wl,-rpath,$(abspath $(OUT)) -Wl,-rpath-link,$(abspath $(OUT)) +endif +ifeq ($(UNAME_S),Darwin) + VIOLAWWW_LDFLAGS += -Wl,-rpath,$(abspath $(OUT)) +endif + +VIOLAWWW_LIBS := \ + -lXm \ + $(VIOLAWWW_X11_LIBS) \ + -lm +ifeq ($(UNAME_S),Darwin) + VIOLAWWW_LIBS += -liconv -framework AudioToolbox -framework CoreFoundation +endif + +$(VIOLAWWW_SOURCE_STAMP): mk/violawww.mk + @echo " GIT $(VIOLAWWW_URL)" + $(Q)mkdir -p $(dir $(VIOLAWWW_DIR)) + $(Q)if [ -e $(VIOLAWWW_DIR) ] && [ ! -d $(VIOLAWWW_DIR)/.git ]; then \ + echo " GIT $(VIOLAWWW_DIR) exists but is not a git checkout" >&2; \ + exit 1; \ + fi + $(Q)test -d $(VIOLAWWW_DIR)/.git || \ + git clone $(MOTIF_GIT_Q) $(VIOLAWWW_CLONE_FLAGS) $(VIOLAWWW_URL) $(VIOLAWWW_DIR) + # Fetch before checkout so a VIOLAWWW_REVISION bump in this file + # stays reproducible against a reused clone: a pinned sha won't + # exist locally until we fetch it. CI uses --no-checkout clone above + # so no moving upstream branch is ever built before this checkout. + # The checkout/reset always names $(VIOLAWWW_REVISION) directly so + # the fallback path (origin's default-branch tip ends up in + # FETCH_HEAD when the pinned sha is unreachable on its own) still + # fails loudly instead of silently building upstream HEAD. + $(Q)cd $(VIOLAWWW_DIR) && git fetch $(MOTIF_GIT_Q) origin $(VIOLAWWW_REVISION) || \ + (cd $(VIOLAWWW_DIR) && git fetch $(MOTIF_GIT_Q) origin) + $(Q)cd $(VIOLAWWW_DIR) && \ + git checkout $(MOTIF_GIT_Q) --detach $(VIOLAWWW_REVISION) && \ + git reset --hard $(MOTIF_GIT_Q) $(VIOLAWWW_REVISION) >/dev/null && \ + git clean $(MOTIF_GIT_Q) -fdx >/dev/null + $(Q)printf '%s\n' '$(VIOLAWWW_REVISION)' > $@ + +$(VIOLAWWW_MOTIF_INCLUDE_STAMP): $(MOTIF_LIBXM) $(MOTIF_LIBMRM) mk/violawww.mk + @mkdir -p $(@D) + $(Q)if [ -L $(VIOLAWWW_MOTIF_INCLUDE)/Xm ]; then rm -f $(VIOLAWWW_MOTIF_INCLUDE)/Xm; fi + $(Q)mkdir -p $(VIOLAWWW_MOTIF_INCLUDE)/Xm + $(Q)find $(VIOLAWWW_MOTIF_INCLUDE)/Xm -type l -delete + $(Q)for header in $(abspath $(MOTIF_SRC_DIR))/lib/Xm/*.h \ + $(abspath $(MOTIF_BUILD_DIR))/lib/Xm/*.h; do \ + ln -sf "$$header" "$(VIOLAWWW_MOTIF_INCLUDE)/Xm/$$(basename "$$header")"; \ + done + $(Q)touch $@ + +.PHONY: violawww check-differential-violawww check-smoke-violawww check-smoke violawww-clean +## Build ViolaWWW against libx11-compat, libXt-compat, libXpm-compat, and in-tree Motif +violawww: $(VIOLAWWW_SOURCE_STAMP) $(VIOLAWWW_MOTIF_INCLUDE_STAMP) $(PKGCONFIG_FILES) \ + $(TARGET) $(LIBXT_TARGET) $(LIBXPM_TARGET) $(XEXT_COMPAT_TARGET) \ + $(XMU_COMPAT_TARGET) $(MOTIF_LIBXM) $(MOTIF_LIBMRM) + @mkdir -p $(VIOLAWWW_BUILD_DIR) + $(Q)rm -rf $(VIOLAWWW_WORK_DIR) + $(Q)mkdir -p $(VIOLAWWW_WORK_DIR) + $(Q)tar --exclude .git --exclude '*.o' --exclude '*.d' \ + --exclude '*.a' --exclude '*.dSYM' --exclude src/vw/vw \ + --exclude src/viola/viola --exclude vplot_dir/vplot \ + -cf - -C $(VIOLAWWW_DIR) . | \ + tar -xf - -C $(VIOLAWWW_WORK_DIR) + $(Q)set -e; for patch in $(VIOLAWWW_PATCHES); do \ + patch -d $(VIOLAWWW_WORK_DIR) -p1 < "$$patch"; \ + done + @echo " MAKE violawww" + $(Q)$(motif_runtime_env) \ + PKG_CONFIG_PATH=$(abspath $(PKGCONFIG_DIR)) \ + $(MAKE) -C $(VIOLAWWW_WORK_DIR) \ + CC='$(CC)' \ + PKG_CONFIG='$(PKG_CONFIG)' \ + OPENMOTIF_PREFIX='$(abspath $(VIOLAWWW_MOTIF_PREFIX))' \ + CFLAGS='$(VIOLAWWW_COMMON_CFLAGS)' \ + CFLAGS_LIBS='$(VIOLAWWW_COMMON_CFLAGS)' \ + X11_CFLAGS='$(VIOLAWWW_X11_CFLAGS)' \ + X11_LIBS='$(VIOLAWWW_X11_LIBS)' \ + LDFLAGS='$(VIOLAWWW_LDFLAGS)' \ + LIBS='$(VIOLAWWW_LIBS)' \ + all > $(VIOLAWWW_LOG) 2>&1 || { \ + echo " FAIL see $(VIOLAWWW_LOG)" >&2; \ + tail -40 $(VIOLAWWW_LOG) >&2; \ + exit 1; \ + } +ifeq ($(UNAME_S),Darwin) + $(Q)for bin in $(VIOLAWWW_VW) $(VIOLAWWW_VIOLA); do \ + test ! -x "$$bin" || install_name_tool \ + -change $(abspath $(OUT))/motif-install/lib/libXm.5.dylib \ + @rpath/libXm.5.dylib "$$bin"; \ + done +endif + +VIOLAWWW_DIFF_REMOTE ?= node11 +VIOLAWWW_DIFF_REMOTE_ROOT ?= /tmp/libx11-compat-violawww-differential +VIOLAWWW_DIFF_DISPLAY ?= 120 +VIOLAWWW_DIFF_JOBS ?= 1 +VIOLAWWW_DIFF_INSTALL_DEPS ?= 0 +VIOLAWWW_DIFF_MAE_THRESHOLD ?= 0.12 +VIOLAWWW_DIFF_CHANGED_THRESHOLD ?= 0.35 +VIOLAWWW_DIFF_SECONDS ?= 5 +VIOLAWWW_DIFF_GEOMETRY ?= 1280x1024x24 +VIOLAWWW_DIFF_TOP ?= 12 +VIOLAWWW_DIFF_COMPARE_LOCATION ?= remote +VIOLAWWW_DIFF_OUT_ROOT ?= $(OUT)/violawww-differential +VIOLAWWW_SMOKE_DEPS ?= violawww + +violawww_diff_env = \ + VIOLAWWW_DIFF_REMOTE='$(VIOLAWWW_DIFF_REMOTE)' \ + VIOLAWWW_DIFF_REMOTE_ROOT='$(VIOLAWWW_DIFF_REMOTE_ROOT)' \ + VIOLAWWW_DIFF_DISPLAY='$(VIOLAWWW_DIFF_DISPLAY)' \ + VIOLAWWW_DIFF_JOBS='$(VIOLAWWW_DIFF_JOBS)' \ + VIOLAWWW_DIFF_MAE_THRESHOLD='$(VIOLAWWW_DIFF_MAE_THRESHOLD)' \ + VIOLAWWW_DIFF_CHANGED_THRESHOLD='$(VIOLAWWW_DIFF_CHANGED_THRESHOLD)' \ + VIOLAWWW_DIFF_SECONDS='$(VIOLAWWW_DIFF_SECONDS)' \ + VIOLAWWW_DIFF_GEOMETRY='$(VIOLAWWW_DIFF_GEOMETRY)' \ + VIOLAWWW_DIFF_TOP='$(VIOLAWWW_DIFF_TOP)' \ + VIOLAWWW_DIFF_COMPARE_LOCATION='$(VIOLAWWW_DIFF_COMPARE_LOCATION)' \ + VIOLAWWW_DIFF_OUT_ROOT='$(abspath $(VIOLAWWW_DIFF_OUT_ROOT))' + +## Compare ViolaWWW screenshots for system libX11 vs libx11-compat on node11 +check-differential-violawww: + $(Q)$(violawww_diff_env) $(PYTHON) scripts/run-violawww-differential-tests.py \ + $(if $(filter 1 yes true,$(VIOLAWWW_DIFF_INSTALL_DEPS)),--install-deps) + +# Pin vw's window at a known origin/size so screenshot crops capture +# the X client's pixels instead of whatever the host desktop happens to +# show. Xt parses -geometry WxH+X+Y itself, so this works without any +# WM cooperation. +VIOLAWWW_SMOKE_GEOMETRY ?= 800x720+0+0 +VIOLAWWW_SMOKE_REGION ?= 0,0,800,720 +VIOLAWWW_HELP_SMOKE_REGION ?= 0,0,1024,720 + +## Run replay-based local ViolaWWW smoke checks against libx11-compat +## (scroll + Help-menu popup). Stamp-prereq pattern matches check-smoke-motif +## in mk/motif.mk so each replay runs even when its output directory already +## exists from a previous invocation (FORCE makes the stamp always rebuild). +check-smoke-violawww: $(UI_SMOKE_OUT_ROOT)/violawww-scroll/.stamp \ + $(UI_SMOKE_OUT_ROOT)/violawww-help/.stamp + +$(UI_SMOKE_OUT_ROOT)/violawww-scroll/.stamp: FORCE $(VIOLAWWW_SMOKE_DEPS) + $(Q)$(PYTHON) scripts/run-ui-replay.py \ + --name violawww-scroll \ + --app $(abspath $(VIOLAWWW_VW)) \ + --app-arg=-geometry --app-arg=$(VIOLAWWW_SMOKE_GEOMETRY) \ + --app-arg file:$(abspath tests/ui/fixtures/violawww-scroll.html) \ + --workdir $(abspath $(VIOLAWWW_WORK_DIR)) \ + --replay tests/ui/replays/violawww-scroll.replay \ + --out-root $(abspath $(UI_SMOKE_OUT_ROOT))/violawww-scroll \ + --display $(UI_REPLAY_DISPLAY) \ + --geometry $(UI_REPLAY_GEOMETRY) \ + --screenshot-command $(UI_REPLAY_SCREENSHOT_COMMAND) \ + --screenshot-region $(VIOLAWWW_SMOKE_REGION) \ + --in-process-snapshots \ + --render-stats $(abspath $(UI_SMOKE_OUT_ROOT))/violawww-scroll/render-stats.tsv \ + $(UI_REPLAY_XVFB) \ + --env DYLD_LIBRARY_PATH=$(abspath $(OUT))$${DYLD_LIBRARY_PATH:+:$$DYLD_LIBRARY_PATH} \ + --env LD_LIBRARY_PATH=$(abspath $(OUT))$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \ + --env WWW_HOME=file:$(abspath tests/ui/fixtures/violawww-scroll.html) \ + --env LIBX11_COMPAT_FONT_DIR=$(abspath $(OUT))/../fonts + $(Q)touch $@ + +$(UI_SMOKE_OUT_ROOT)/violawww-help/.stamp: FORCE $(VIOLAWWW_SMOKE_DEPS) + $(Q)$(PYTHON) scripts/run-ui-replay.py \ + --name violawww-help \ + --app $(abspath $(VIOLAWWW_VW)) \ + --app-arg=-geometry --app-arg=$(VIOLAWWW_SMOKE_GEOMETRY) \ + --app-arg file:$(abspath tests/ui/fixtures/violawww-scroll.html) \ + --workdir $(abspath $(VIOLAWWW_WORK_DIR)) \ + --replay tests/ui/replays/violawww-help.replay \ + --out-root $(abspath $(UI_SMOKE_OUT_ROOT))/violawww-help \ + --display $(UI_REPLAY_DISPLAY) \ + --geometry $(UI_REPLAY_GEOMETRY) \ + --screenshot-command $(UI_REPLAY_SCREENSHOT_COMMAND) \ + --screenshot-region $(VIOLAWWW_HELP_SMOKE_REGION) \ + $(UI_REPLAY_XVFB) \ + --env DYLD_LIBRARY_PATH=$(abspath $(OUT))$${DYLD_LIBRARY_PATH:+:$$DYLD_LIBRARY_PATH} \ + --env LD_LIBRARY_PATH=$(abspath $(OUT))$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \ + --env WWW_HOME=file:$(abspath tests/ui/fixtures/violawww-scroll.html) \ + --env LIBX11_COMPAT_FONT_DIR=$(abspath $(OUT))/../fonts + $(Q)touch $@ + +## Run local replay-only UI smoke checks that do not require node11 +check-smoke: check-smoke-violawww check-smoke-motif + +violawww-clean: + @echo " CLEAN violawww" + $(Q)rm -rf $(VIOLAWWW_BUILD_DIR) diff --git a/mk/xcompat-libs.mk b/mk/xcompat-libs.mk index 6f9e131..3b5e58f 100644 --- a/mk/xcompat-libs.mk +++ b/mk/xcompat-libs.mk @@ -7,17 +7,17 @@ XINERAMA_COMPAT_LDFLAGS := $(call shared_lib_rpath_ldflags,$(notdir $(XINERAMA_C $(OUT)/xext-compat.o: compat/xext-compat.c $(UPSTREAM_HEADERS_STAMP) | $(OUT) @echo " CC $<" - $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(CFLAGS_EXTRA) \ + $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(STRICT_CFLAGS) $(CFLAGS_EXTRA) \ -MMD -MP -MF $(@:.o=.d) -MT $@ -MT $(@:.o=.d) -c $< -o $@ $(OUT)/xmu-compat.o: compat/xmu-compat.c $(UPSTREAM_HEADERS_STAMP) | $(OUT) @echo " CC $<" - $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(CFLAGS_EXTRA) \ + $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(STRICT_CFLAGS) $(CFLAGS_EXTRA) \ -MMD -MP -MF $(@:.o=.d) -MT $@ -MT $(@:.o=.d) -c $< -o $@ $(OUT)/xinerama-compat.o: compat/xinerama-compat.c $(UPSTREAM_HEADERS_STAMP) | $(OUT) @echo " CC $<" - $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(CFLAGS_EXTRA) \ + $(Q)$(CC) $(CPPFLAGS) $(CFLAGS) $(STRICT_CFLAGS) $(CFLAGS_EXTRA) \ -MMD -MP -MF $(@:.o=.d) -MT $@ -MT $(@:.o=.d) -c $< -o $@ $(XEXT_COMPAT_TARGET): $(OUT)/xext-compat.o $(TARGET) | $(OUT) diff --git a/scripts/capture-motif-demo-screenshots.sh b/scripts/capture-motif-demo-screenshots.sh index ea09013..f038ce1 100755 --- a/scripts/capture-motif-demo-screenshots.sh +++ b/scripts/capture-motif-demo-screenshots.sh @@ -274,6 +274,7 @@ while IFS= read -r exe; do app_res_dir=$(motif_app_resource_dir "$rel") xappresdir="$app_res_dir" xfile_search_path=$(motif_xfile_search_path "$app_res_dir") + compat_font_dir=${LIBX11_COMPAT_FONT_DIR:-"$abs_out_dir/../fonts"} set -- "$run_exe" count=$((count + 1)) printf 'SHOT %s\n' "$rel" @@ -344,6 +345,7 @@ EOF exec env -u SDL_VIDEODRIVER \ DYLD_LIBRARY_PATH="$lib_path${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ LD_LIBRARY_PATH="$lib_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + LIBX11_COMPAT_FONT_DIR="$compat_font_dir" \ XFILESEARCHPATH="$xfile_search_path${XFILESEARCHPATH:+:$XFILESEARCHPATH}" \ XAPPLRESDIR="$xappresdir" \ HOME="${home_dir:-$HOME}" \ @@ -355,6 +357,7 @@ EOF exec env -u SDL_VIDEODRIVER \ DYLD_LIBRARY_PATH="$lib_path${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" \ LD_LIBRARY_PATH="$lib_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + LIBX11_COMPAT_FONT_DIR="$compat_font_dir" \ XFILESEARCHPATH="$xfile_search_path${XFILESEARCHPATH:+:$XFILESEARCHPATH}" \ XAPPLRESDIR="$xappresdir" \ HOME="${home_dir:-$HOME}" \ diff --git a/scripts/compare-motif-reference.py b/scripts/compare-motif-reference.py index 56638da..e2edff4 100755 --- a/scripts/compare-motif-reference.py +++ b/scripts/compare-motif-reference.py @@ -53,6 +53,8 @@ def read_results(path): for row in reader: if not row.get("relative_path"): continue + if row.get("status") == "ok" and not row.get("screenshot"): + continue results[screenshot_name_for_result(row)] = row return results diff --git a/scripts/embed-bdf-font.py b/scripts/embed-bdf-font.py new file mode 100644 index 0000000..7cfcd24 --- /dev/null +++ b/scripts/embed-bdf-font.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Generate a C source array from a 6x13 / 7x14 / similar 1-byte-per-row BDF. + +Output is a static const unsigned char [CHAR_COUNT][HEIGHT] array suitable +for #include into src/font.c so the fixed-bitmap renderer does not need +the BDF file at runtime. Only the first CHAR_COUNT encodings (default 128) +are decoded. Rows that do not appear in the source remain 0. +""" + +import argparse +import sys +from pathlib import Path + + +def decode_bdf(text: str, char_count: int, height: int): + rows = [[0] * height for _ in range(char_count)] + encoding = None + bitmap_row = -1 + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("STARTCHAR"): + encoding = None + bitmap_row = -1 + continue + if stripped.startswith("ENCODING "): + try: + encoding = int(stripped.split()[1]) + except (IndexError, ValueError): + encoding = None + continue + if stripped == "BITMAP" or stripped.startswith("BITMAP"): + if encoding is not None and 0 <= encoding < char_count: + bitmap_row = 0 + else: + bitmap_row = -1 + continue + if stripped.startswith("ENDCHAR"): + bitmap_row = -1 + continue + if bitmap_row >= 0 and bitmap_row < height: + token = stripped.split()[0] if stripped else "" + if len(token) in (1, 2) and all( + c in "0123456789abcdefABCDEF" for c in token + ): + rows[encoding][bitmap_row] = int(token, 16) + bitmap_row += 1 + return rows + + +def emit(rows, char_count: int, height: int, name: str): + out = [] + out.append("/* Generated by scripts/embed-bdf-font.py. Do not edit. */") + out.append(f"static const unsigned char {name}[{char_count}][{height}] = {{") + for ch, row_bytes in enumerate(rows): + joined = ", ".join(f"0x{b:02x}" for b in row_bytes) + comma = "," if ch + 1 < char_count else "" + out.append(f" {{ {joined} }}{comma}") + out.append("};") + return "\n".join(out) + "\n" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("bdf", type=Path) + parser.add_argument("--char-count", type=int, default=128) + parser.add_argument("--height", type=int, default=13) + parser.add_argument("--name", default="EMBEDDED_FIXED_BITMAP_ROWS") + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + + rows = decode_bdf(args.bdf.read_text(), args.char_count, args.height) + args.out.write_text(emit(rows, args.char_count, args.height, args.name)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run-motif-differential-tests.py b/scripts/run-motif-differential-tests.py index 819c2b7..171319b 100755 --- a/scripts/run-motif-differential-tests.py +++ b/scripts/run-motif-differential-tests.py @@ -96,10 +96,12 @@ def remote_script(args, remote_repo): sudo apt-get install -y --no-install-recommends \\ autoconf automake bison build-essential ca-certificates git \\ imagemagick libtool make pkg-config rsync xauth xvfb \\ - libice-dev libsm-dev libx11-dev libxext-dev libxmu-dev libxt-dev + xdotool libice-dev libsm-dev libx11-dev libxext-dev libxmu-dev libxt-dev fi """ + replay_deps = "need xdotool\n" if args.replay_smoke else "" + return f""" set -eu @@ -121,7 +123,7 @@ def remote_script(args, remote_repo): need python3 need rsync need Xvfb -if command -v yacc >/dev/null 2>&1; then +{replay_deps}if command -v yacc >/dev/null 2>&1; then yacc_bin=yacc elif command -v bison >/dev/null 2>&1; then yacc_bin="bison -y" @@ -145,9 +147,9 @@ def remote_script(args, remote_repo): run_logged() {{ log=$1 shift - # Capture $? inside the else-branch: after `fi` the exit status of + # Capture $? inside the else-branch: after fi the exit status of # the if-statement is 0 when no branch ran (POSIX), which would mask - # the original failure if we read $? on the line below `fi`. + # the original failure if we read $? on the line below fi. if "$@" >>"$log" 2>&1; then return 0 else @@ -158,6 +160,62 @@ def remote_script(args, remote_repo): fi }} +write_wsm_home() {{ + home_dir=$1 + mkdir -p "$home_dir" + cat >"$home_dir/.wsmdb" <<'WSMEOF' +wsm_WSM.WSM.0.linked:True +wsm_WSM.WSM.0.allWorkspaces:True +wsm_WSM.WSM.0.linkedRoom.hidden:0 +saveAsShell_WSM*allWorkspaces:True +configureShell_WSM*allWorkspaces:True +nameShell_WSM*allWorkspaces: True +backgroundShell_WSM*allWorkspaces:True +deleteShell_WSM*allWorkspaces:True +occupyShell_WSM*allWorkspaces:True +WSMEOF +}} + +run_motif_replay() {{ + name=$1 + replay=$2 + app=$3 + workdir=$4 + libpath=$5 + log_dir=$6 + screen_dir=$7 + home_dir=$8 + input_backend=$9 + replay_out="$remote_root/replay-$name" + rm -rf "$replay_out" + mkdir -p "$log_dir" "$screen_dir" + python3 "$repo/scripts/run-ui-replay.py" \\ + --name "$name" \\ + --app "$app" \\ + --workdir "$workdir" \\ + --replay "$repo/tests/ui/replays/$replay" \\ + --out-root "$replay_out" \\ + --display {q(args.display)} \\ + --geometry {q(args.geometry)} \\ + --input-backend "$input_backend" \\ + --screenshot-command import \\ + --env DISPLAY="$display" \\ + --env HOME="$home_dir" \\ + --env LD_LIBRARY_PATH="$libpath${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}" \\ + --env XAPPLRESDIR="$(dirname "$app")" \\ + --env XFILESEARCHPATH="$(dirname "$app")/%N.ad:$(dirname "$app")/%N:$motif_src/demos/$(basename "$(dirname "$app")")/%N.ad:$motif_src/demos/$(basename "$(dirname "$app")")/%N" + # Replay assertions are enforced by run-ui-replay itself. Keep their + # screenshots out of the differential screenshot directories so these + # smoke interactions do not require symmetric reference/local frames. + cp "$replay_out"/junit.xml "$log_dir/$name-junit.xml" + if [ -f "$log_dir/results.tsv" ]; then + tail -n +2 "$replay_out/results.tsv" >>"$log_dir/results.tsv" + else + cp "$replay_out/results.tsv" "$log_dir/results.tsv" + fi + cp "$replay_out"/logs/* "$log_dir"/ 2>/dev/null || true +}} + {clean_remote} mkdir -p "$system_build" "$system_out" "$system_screens" "$compat_screens" \\ "$system_logs" "$compat_logs" @@ -172,7 +230,7 @@ def remote_script(args, remote_repo): : >"$system_config_log" run_logged "$system_config_log" env \\ CPP="gcc -E" \\ - CFLAGS="-g -O0" \\ + CFLAGS="-g -O0 -include stdlib.h" \\ YACC="$yacc_bin" \\ "$motif_src/configure" \\ --prefix="$system_out/motif-install" \\ @@ -189,11 +247,11 @@ def remote_script(args, remote_repo): touch .configure-stamp fi run_logged "$system_build_log" make -C config -run_logged "$system_build_log" make -C lib/Xm CFLAGS="-g -O0" -run_logged "$system_build_log" make -C lib/Mrm CFLAGS="-g -O0" -run_logged "$system_build_log" make -C tools/wml CPP="gcc -E" CFLAGS="-g -O0" -run_logged "$system_build_log" make -C clients/uil CPP="gcc -E" CFLAGS="-g -O0" -run_logged "$system_build_log" make -C demos CPP="gcc -E" CFLAGS="-g -O0" +run_logged "$system_build_log" make -C lib/Xm CFLAGS="-g -O0 -include stdlib.h" +run_logged "$system_build_log" make -C lib/Mrm CFLAGS="-g -O0 -include stdlib.h" +run_logged "$system_build_log" make -C tools/wml CPP="gcc -E" CFLAGS="-g -O0 -include stdlib.h" +run_logged "$system_build_log" make -C clients/uil CPP="gcc -E" CFLAGS="-g -O0 -include stdlib.h" +run_logged "$system_build_log" make -C demos CPP="gcc -E" CFLAGS="-g -O0 -include stdlib.h" rm -f "/tmp/.X{q(args.display)}-lock" Xvfb "$display" -screen 0 {q(args.geometry)} \\ @@ -216,6 +274,53 @@ def remote_script(args, remote_repo): export MOTIF_DEMO_SCREENSHOT_LOG_DIR="$compat_logs" sh "$repo/scripts/capture-motif-demo-screenshots.sh" \\ "$repo/build/motif-demos" "$repo/build" + +if [ {q("1" if args.replay_smoke else "0")} = 1 ]; then + mkdir -p "$remote_root/home-system" "$remote_root/home-compat" \\ + "$remote_root/home-system-wsm" "$remote_root/home-compat-wsm" + write_wsm_home "$remote_root/home-system-wsm" + write_wsm_home "$remote_root/home-compat-wsm" + run_motif_replay \\ + motif-fileview-done \\ + motif-fileview-done.replay \\ + "$system_build/demos/programs/fileview/fileview" \\ + "$system_build/demos/programs/fileview" \\ + "$system_build/lib/Xm/.libs:$system_build/lib/Mrm/.libs:$system_build/clients/uil/.libs" \\ + "$system_logs" \\ + "$system_screens" \\ + "$remote_root/home-system" \\ + xdotool + run_motif_replay \\ + motif-fileview-done \\ + motif-fileview-done.replay \\ + "$repo/build/motif-demos/demos/programs/fileview/fileview" \\ + "$repo/build/motif-demos/demos/programs/fileview" \\ + "$repo/build/motif-demos/lib/Xm/.libs:$repo/build/motif-demos/lib/Mrm/.libs:$repo/build/motif-demos/clients/uil/.libs:$repo/build" \\ + "$compat_logs" \\ + "$compat_screens" \\ + "$remote_root/home-compat" \\ + internal + run_motif_replay \\ + motif-wsm-labels \\ + motif-wsm-labels.replay \\ + "$system_build/demos/programs/workspace/wsm" \\ + "$system_build/demos/programs/workspace" \\ + "$system_build/lib/Xm/.libs:$system_build/lib/Mrm/.libs:$system_build/clients/uil/.libs" \\ + "$system_logs" \\ + "$system_screens" \\ + "$remote_root/home-system-wsm" \\ + xdotool + run_motif_replay \\ + motif-wsm-labels \\ + motif-wsm-labels.replay \\ + "$repo/build/motif-demos/demos/programs/workspace/wsm" \\ + "$repo/build/motif-demos/demos/programs/workspace" \\ + "$repo/build/motif-demos/lib/Xm/.libs:$repo/build/motif-demos/lib/Mrm/.libs:$repo/build/motif-demos/clients/uil/.libs:$repo/build" \\ + "$compat_logs" \\ + "$compat_screens" \\ + "$remote_root/home-compat-wsm" \\ + internal +fi """ @@ -352,15 +457,20 @@ def main(): action="store_true", help="install minimal Ubuntu packages on the remote via sudo apt-get", ) + # Defaults match mk/motif.mk. Kept tight enough that a missing widget + # or wrong-color background trips the gate, but loose enough to + # tolerate font hinting / AA differences between Xft on system X11 + # and our SDL_ttf path. Override per-job via MOTIF_DIFF_*_THRESHOLD + # when adding demos that legitimately render differently. parser.add_argument( "--mae-threshold", type=float, - default=float(parse_env_default("MOTIF_DIFF_MAE_THRESHOLD", "0.08")), + default=float(parse_env_default("MOTIF_DIFF_MAE_THRESHOLD", "0.04")), ) parser.add_argument( "--changed-threshold", type=float, - default=float(parse_env_default("MOTIF_DIFF_CHANGED_THRESHOLD", "0.35")), + default=float(parse_env_default("MOTIF_DIFF_CHANGED_THRESHOLD", "0.20")), ) parser.add_argument( "--top", @@ -382,6 +492,16 @@ def main(): "runs on the remote" ), ) + parser.add_argument( + "--replay-smoke", + action="store_true", + default=parse_env_default("MOTIF_DIFF_REPLAY", "0").lower() + in ("1", "yes", "true"), + help=( + "also run replay-based Motif UI interactions on system-X11 and " + "libx11-compat; currently includes the fileview Done-button path" + ), + ) args = parser.parse_args() if not re.fullmatch(r"\d+", args.display): diff --git a/scripts/run-ui-replay.py b/scripts/run-ui-replay.py new file mode 100755 index 0000000..af68b5e --- /dev/null +++ b/scripts/run-ui-replay.py @@ -0,0 +1,1040 @@ +#!/usr/bin/env python3 +import argparse +import csv +import json +import os +import re +import shlex +import shutil +import signal +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +try: + from PIL import Image, ImageChops +except ImportError: + Image = None + ImageChops = None + + +ROOT = Path(__file__).resolve().parents[1] + + +class ReplayError(Exception): + pass + + +def resolve_path(path, *bases): + candidate = Path(path) + if candidate.is_absolute(): + return candidate + for base in bases: + resolved = base / candidate + if resolved.exists(): + return resolved + return bases[0] / candidate + + +def split_env(values): + env = {} + for value in values: + if "=" not in value: + raise ReplayError(f"--env value must be NAME=VALUE: {value}") + key, val = value.split("=", 1) + env[key] = val + return env + + +def ensure_pil(): + if Image is None: + raise ReplayError("Pillow is required for image assertions") + + +def run_command(cmd, *, cwd=None, env=None, stdout=None, stderr=None): + print("+", " ".join(str(c) for c in cmd), flush=True) + return subprocess.run(cmd, cwd=cwd, env=env, stdout=stdout, stderr=stderr) + + +def start_xvfb(display, geometry, log_path): + if not display: + return None + if os.environ.get("DISPLAY") == f":{display}": + return None + if not shutil.which("Xvfb"): + return None + lock = Path(f"/tmp/.X{display}-lock") + if lock.exists(): + try: + lock.unlink() + except OSError: + pass + log = log_path.open("w") + proc = subprocess.Popen( + ["Xvfb", f":{display}", "-screen", "0", geometry], + stdout=log, + stderr=subprocess.STDOUT, + ) + time.sleep(1.0) + if proc.poll() is not None: + raise ReplayError(f"Xvfb exited early with status {proc.returncode}") + return proc + + +def terminate_process(proc): + if proc.poll() is not None: + return proc.returncode + try: + proc.terminate() + except ProcessLookupError: + return proc.poll() + deadline = time.time() + 2.0 + while time.time() < deadline: + if proc.poll() is not None: + return proc.returncode + time.sleep(0.1) + try: + proc.kill() + except ProcessLookupError: + pass + return proc.wait() + + +def command_exists(name): + return shutil.which(name) is not None + + +def xdotool(env, *args, check=True, capture=False): + if not command_exists("xdotool"): + raise ReplayError("xdotool is required for replay input commands") + kwargs = { + "env": env, + "text": True, + "check": check, + } + if capture: + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.PIPE + print("+ xdotool", " ".join(args), flush=True) + try: + return subprocess.run(["xdotool", *args], **kwargs) + except subprocess.CalledProcessError as exc: + # Re-raise as ReplayError so the replay loop's error-handling + # path records a deterministic failure instead of letting + # CalledProcessError escape and crash the runner. + detail = exc.stderr.strip() if isinstance(exc.stderr, str) else "" + suffix = f": {detail}" if detail else "" + raise ReplayError( + f"xdotool {' '.join(args)} failed with status {exc.returncode}{suffix}" + ) from exc + + +def wait_window(env, pattern, timeout_ms): + deadline = time.time() + timeout_ms / 1000.0 + last_error = "" + while time.time() < deadline: + if command_exists("xdotool") and env.get("DISPLAY"): + for field in ("--name", "--class"): + result = xdotool( + env, + "search", + field, + pattern, + check=False, + capture=True, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip().splitlines()[0] + last_error = result.stderr.strip() + time.sleep(0.2) + raise ReplayError(f"timed out waiting for window {pattern!r}: {last_error}") + + +def wait_process_alive(proc, timeout_ms): + """Poll the app for the requested duration, failing fast on exit. + + The internal replay backend has no X server to query, so wait-window + degrades into a fixed-duration alive check. Returning after the first + sleep tick (the prior behavior) collapsed every wait-window into a + 100 ms delay regardless of timeout_ms. + """ + deadline = time.time() + timeout_ms / 1000.0 + while time.time() < deadline: + if proc.poll() is not None: + raise ReplayError(f"process exited with status {proc.returncode}") + time.sleep(0.1) + + +def write_internal_replay(source_path, dest_path, snapshot_dir=None): + """Translate runner replay commands to the in-process replay engine. + + The library-side engine handles input/timing commands and, when + snapshot_dir is provided, also writes per-screenshot BMP files via + libx11-compat's snapshot helper. The runner reads those BMPs back + instead of running screencapture, which on macOS deactivates the + target NSApp briefly and stalls SDL's event pump (verified + empirically -- wheel events queued mid-screencapture never reach + the X client). + """ + lines = [ + "# generated by scripts/run-ui-replay.py", + "# consumed by libx11-compat's LIBX11_COMPAT_REPLAY engine", + ] + for lineno, parts in parse_replay(source_path): + command = parts[0] + if command == "delay": + if len(parts) != 2: + raise ReplayError(f"{source_path}:{lineno}: delay expects milliseconds") + lines.append(f"delay {int(parts[1])}") + elif command == "motion": + if len(parts) != 3: + raise ReplayError(f"{source_path}:{lineno}: motion expects x y") + lines.append(f"motion {int(parts[1])} {int(parts[2])}") + elif command == "button": + if len(parts) != 3: + raise ReplayError( + f"{source_path}:{lineno}: button expects n press|release|click" + ) + button = int(parts[1]) + if parts[2] == "click": + lines.append(f"button {button} press") + lines.append("delay 10") + lines.append(f"button {button} release") + elif parts[2] in ("press", "release"): + lines.append(f"button {button} {parts[2]}") + else: + raise ReplayError( + f"{source_path}:{lineno}: button action must be press, release, or click" + ) + elif command == "wheel": + if len(parts) != 3: + raise ReplayError( + f"{source_path}:{lineno}: wheel expects up|down count" + ) + if parts[1] not in ("up", "down"): + raise ReplayError( + f"{source_path}:{lineno}: wheel direction must be up or down" + ) + button = 4 if parts[1] == "up" else 5 + for _ in range(int(parts[2])): + lines.append(f"button {button} press") + lines.append(f"button {button} release") + lines.append("delay 50") + elif command == "key": + if len(parts) != 2: + raise ReplayError(f"{source_path}:{lineno}: key expects scancode") + lines.append(f"key {int(parts[1])} press") + lines.append("delay 10") + lines.append(f"key {int(parts[1])} release") + elif command == "screenshot": + if snapshot_dir is None: + # Host screenshots are invisible to the in-process replay + # thread. Preserve ordering so input after a screenshot does + # not race ahead and contaminate the captured baseline. + lines.append("delay 500") + continue + if len(parts) < 2: + raise ReplayError(f"{source_path}:{lineno}: screenshot needs a name") + name = parts[1] + # BMP, written by SDL_SaveBMP; the runner converts to PNG + # after the replay completes. + lines.append(f"snapshot {snapshot_dir}/{name}.bmp") + elif command == "resize": + if len(parts) != 3: + raise ReplayError(f"{source_path}:{lineno}: resize expects W H") + lines.append(f"resize {int(parts[1])} {int(parts[2])}") + elif command == "wait-window": + if len(parts) != 3: + raise ReplayError( + f"{source_path}:{lineno}: wait-window expects pattern timeout_ms" + ) + lines.append(f"delay {int(parts[2])}") + elif command in ("assert-image", "assert-exit"): + continue + else: + raise ReplayError( + f"{source_path}:{lineno}: unknown replay command {command}" + ) + + dest_path.parent.mkdir(parents=True, exist_ok=True) + dest_path.write_text("\n".join(lines) + "\n") + return dest_path + + +def capture_screen(path, env, command, region): + """Capture either the full screen or a fixed pixel region. + + region is an (x, y, w, h) tuple in display coordinates, or None for + full-screen. macOS screencapture, ImageMagick import, and the + in-process pixman snapshot helper all support the region form; only + gnome-screenshot has no region equivalent (it captures the whole + screen and we crop afterward). + """ + path.parent.mkdir(parents=True, exist_ok=True) + if command == "auto": + if env.get("DISPLAY") and command_exists("import"): + command = "import" + elif command_exists("gnome-screenshot"): + command = "gnome-screenshot" + elif command_exists("screencapture"): + command = "screencapture" + else: + raise ReplayError( + "no screenshot command found; install ImageMagick import, " + "gnome-screenshot, or macOS screencapture" + ) + + if command == "import": + cmd = ["import", "-window", "root"] + if region is not None: + x, y, w, h = region + cmd += ["-crop", f"{w}x{h}+{x}+{y}", "+repage"] + cmd.append(str(path)) + result = run_command(cmd, env=env) + elif command == "gnome-screenshot": + result = run_command(["gnome-screenshot", "-f", str(path)], env=env) + if result.returncode == 0 and region is not None: + ensure_pil() + full = Image.open(path) + x, y, w, h = region + full.crop((x, y, x + w, y + h)).save(path) + elif command == "screencapture": + cmd = ["screencapture", "-x"] + if region is not None: + x, y, w, h = region + cmd += ["-R", f"{x},{y},{w},{h}"] + cmd.append(str(path)) + result = run_command(cmd, env=env) + else: + raise ReplayError(f"unknown screenshot command: {command}") + if result.returncode != 0: + raise ReplayError(f"screenshot command failed for {path}") + if not path.exists() or path.stat().st_size == 0: + raise ReplayError(f"screenshot is empty: {path}") + + +def parse_replay(path): + steps = [] + with path.open() as f: + for lineno, line in enumerate(f, 1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + try: + parts = shlex.split(stripped) + except ValueError as error: + raise ReplayError(f"{path}:{lineno}: {error}") from error + steps.append((lineno, parts)) + return steps + + +def crop(img, rect): + if rect is None: + return img + x, y, w, h = rect + return img.crop((x, y, x + w, y + h)) + + +def assertion_rect(rule, img): + if "display_rect" not in rule: + return rule.get("rect") + base = rule.get("display_size") + if not base or len(base) != 2: + raise ReplayError("display_rect assertions require display_size [w, h]") + base_w, base_h = base + if base_w <= 0 or base_h <= 0: + raise ReplayError("display_size dimensions must be positive") + sx = img.width / base_w + sy = img.height / base_h + x, y, w, h = rule["display_rect"] + return [ + int(round(x * sx)), + int(round(y * sy)), + int(round(w * sx)), + int(round(h * sy)), + ] + + +def dark_mask_stats(img, dark_threshold): + """Count pixels at or below dark_threshold on all three channels. + + Iterating raw bytes is ~50x faster than Image.getdata() over the + Python iterator protocol on large frames, and Pillow 14 deprecates + getdata() outright. + """ + rgb = img.convert("RGB") + raw = rgb.tobytes() + total = max(1, len(raw) // 3) + dark = 0 + for offset in range(0, len(raw), 3): + if ( + raw[offset] <= dark_threshold + and raw[offset + 1] <= dark_threshold + and raw[offset + 2] <= dark_threshold + ): + dark += 1 + return dark, total + + +def dense_dark_rows(img, dark_threshold, max_row_ratio): + rgb = img.convert("RGB") + raw = rgb.tobytes() + width = max(1, rgb.width) + row_stride = width * 3 + dense_rows = 0 + for y in range(rgb.height): + row_dark = 0 + row_start = y * row_stride + row_end = row_start + row_stride + for offset in range(row_start, row_end, 3): + if ( + raw[offset] <= dark_threshold + and raw[offset + 1] <= dark_threshold + and raw[offset + 2] <= dark_threshold + ): + row_dark += 1 + if row_dark / width > max_row_ratio: + dense_rows += 1 + return dense_rows + + +def image_changed_ratio(a, b): + width = min(a.width, b.width) + height = min(a.height, b.height) + if width <= 0 or height <= 0: + return 0.0 + a = a.crop((0, 0, width, height)).convert("RGB") + b = b.crop((0, 0, width, height)).convert("RGB") + diff = ImageChops.difference(a, b).tobytes() + changed = 0 + total = width * height + for offset in range(0, len(diff), 3): + if diff[offset] + diff[offset + 1] + diff[offset + 2] > 24: + changed += 1 + return changed / total + + +def assert_image(rule_path, image_path, screenshots, assertion_base): + ensure_pil() + if not rule_path.exists(): + raise ReplayError(f"assertion file not found: {rule_path}") + with rule_path.open() as f: + rule_doc = json.load(f) + img = Image.open(image_path).convert("RGBA") + failures = [] + observations = [] + + for rule in rule_doc.get("assertions", []): + rule_type = rule.get("type") + if rule_type == "non_empty": + if image_path.stat().st_size <= 0 or img.width <= 0 or img.height <= 0: + failures.append("image is empty") + elif rule_type == "not_all_black": + extrema = img.convert("RGB").getextrema() + if max(channel[1] for channel in extrema) <= 8: + failures.append("image is all black") + elif rule_type == "region_non_background": + region = crop(img, assertion_rect(rule, img)) + dark, total = dark_mask_stats(region, int(rule.get("dark_threshold", 96))) + ratio = dark / total + if ratio < float(rule.get("min_dark_ratio", 0.001)): + failures.append( + f"region dark ratio {ratio:.5f} below " + f"{float(rule.get('min_dark_ratio', 0.001)):.5f}" + ) + elif rule_type == "region_max_dark_ratio": + region = crop(img, assertion_rect(rule, img)) + dark, total = dark_mask_stats(region, int(rule.get("dark_threshold", 32))) + ratio = dark / total + if ratio > float(rule.get("max_dark_ratio", 0.35)): + failures.append( + f"region dark ratio {ratio:.5f} above " + f"{float(rule.get('max_dark_ratio', 0.35)):.5f}" + ) + elif rule_type == "changed_region": + baseline_name = rule.get("baseline") + if baseline_name not in screenshots: + failures.append(f"missing baseline screenshot {baseline_name}") + continue + baseline = Image.open(screenshots[baseline_name]).convert("RGBA") + rect = assertion_rect(rule, img) + ratio = image_changed_ratio(crop(img, rect), crop(baseline, rect)) + if ratio < float(rule.get("min_changed_ratio", 0.01)): + failures.append( + f"changed ratio {ratio:.5f} below " + f"{float(rule.get('min_changed_ratio', 0.01)):.5f}" + ) + elif rule_type == "reference_diff": + reference = resolve_path( + rule.get("reference", ""), + assertion_base, + assertion_base.parent, + ROOT, + ) + if not reference.exists(): + failures.append(f"reference image not found: {reference}") + continue + ref_img = Image.open(reference).convert("RGBA") + rect = assertion_rect(rule, img) + ratio = image_changed_ratio(crop(img, rect), crop(ref_img, rect)) + if ratio > float(rule.get("max_changed_ratio", 0.35)): + failures.append( + f"reference changed ratio {ratio:.5f} above " + f"{float(rule.get('max_changed_ratio', 0.35)):.5f}" + ) + elif rule_type == "stale_text": + region = crop(img, rule.get("rect")).convert("RGB") + threshold = int(rule.get("dark_threshold", 80)) + max_row_ratio = float(rule.get("max_row_dark_ratio", 0.42)) + max_dense_rows = int(rule.get("max_dense_rows", 18)) + dense_rows = dense_dark_rows(region, threshold, max_row_ratio) + observations.append( + { + "metric": "stale_text_dense_rows", + "value": dense_rows, + "detail": str(rule_path), + } + ) + if dense_rows > max_dense_rows: + failures.append( + f"stale-text dense rows {dense_rows} above {max_dense_rows}" + ) + else: + failures.append(f"unknown assertion type {rule_type}") + + if failures: + rel_rule = rule_path + try: + rel_rule = rule_path.relative_to(assertion_base) + except ValueError: + pass + raise ReplayError(f"{image_path.name} failed {rel_rule}: {'; '.join(failures)}") + return observations + + +def write_results(path, rows): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as f: + fields = ["status", "relative_path", "screenshot", "detail"] + writer = csv.DictWriter(f, fieldnames=fields, delimiter="\t") + writer.writeheader() + for row in rows: + writer.writerow({field: row.get(field, "") for field in fields}) + + +def add_metric( + rows, lineno, command, target, metric, value="", duration_ms="", detail="" +): + rows.append( + { + "lineno": lineno, + "command": command, + "target": target, + "metric": metric, + "value": value, + "duration_ms": duration_ms, + "detail": detail, + } + ) + + +def write_metrics(path, rows): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as f: + fields = [ + "lineno", + "command", + "target", + "metric", + "value", + "duration_ms", + "detail", + ] + writer = csv.DictWriter(f, fieldnames=fields, delimiter="\t") + writer.writeheader() + for row in rows: + writer.writerow({field: row.get(field, "") for field in fields}) + + +def write_junit(path, name, rows): + failures = [row for row in rows if row.get("status") not in ("captured", "ok")] + suite = ET.Element( + "testsuite", + { + "name": name, + "tests": str(len(rows)), + "failures": str(len(failures)), + "errors": "0", + }, + ) + for row in rows: + case = ET.SubElement( + suite, + "testcase", + { + "classname": name, + "name": row.get("relative_path") or row.get("screenshot") or "step", + }, + ) + if row in failures: + failure = ET.SubElement( + case, + "failure", + { + "type": row.get("status", "failed"), + "message": row.get("detail", ""), + }, + ) + failure.text = row.get("detail", "") + path.parent.mkdir(parents=True, exist_ok=True) + ET.ElementTree(suite).write(path, encoding="utf-8", xml_declaration=True) + + +def run_replay(args): + replay_path = resolve_path(args.replay, ROOT / "tests/ui/replays", ROOT) + assertion_base = ROOT / "tests/ui/assertions" + out_root = args.out_root + screenshot_dir = out_root / "screens" + log_dir = out_root / "logs" + out_root.mkdir(parents=True, exist_ok=True) + if screenshot_dir.exists(): + shutil.rmtree(screenshot_dir) + if log_dir.exists(): + shutil.rmtree(log_dir) + screenshot_dir.mkdir(parents=True) + log_dir.mkdir(parents=True) + + env = os.environ.copy() + env.update(split_env(args.env)) + if args.render_stats is not None: + args.render_stats.parent.mkdir(parents=True, exist_ok=True) + env["LIBX11_COMPAT_RENDER_STATS"] = str(args.render_stats) + display = args.display + xvfb_proc = None + if args.xvfb: + xvfb_proc = start_xvfb(display, args.geometry, log_dir / "xvfb.log") + env["DISPLAY"] = f":{display}" + snapshot_dir = None + if args.input_backend == "internal" and args.in_process_snapshots: + snapshot_dir = out_root / "snapshots" + # Wipe stale BMPs from a previous run; otherwise an earlier + # screenshot step's output could satisfy a later assertion + # against a snapshot the current run never produced. + if snapshot_dir.exists(): + shutil.rmtree(snapshot_dir) + snapshot_dir.mkdir(parents=True) + if args.input_backend == "internal": + internal_replay = write_internal_replay( + replay_path, + out_root / "internal.replay", + snapshot_dir=snapshot_dir, + ) + env["LIBX11_COMPAT_REPLAY"] = str(internal_replay) + + app_log = (log_dir / f"{args.name}.log").open("w") + app_cmd = [str(args.app), *args.app_arg] + print("+", " ".join(shlex.quote(str(c)) for c in app_cmd), flush=True) + proc = subprocess.Popen( + app_cmd, + cwd=args.workdir, + env=env, + stdout=app_log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + rows = [] + metrics = [] + screenshots = {} + failed = False + target_window_id = None + try: + for lineno, parts in parse_replay(replay_path): + command = parts[0] + target = " ".join(parts[1:]) + step_start = time.perf_counter() + try: + if command == "delay": + if len(parts) != 2: + raise ReplayError("delay expects milliseconds") + time.sleep(int(parts[1]) / 1000.0) + elif command == "wait-window": + if len(parts) != 3: + raise ReplayError("wait-window expects regex timeout-ms") + if args.input_backend == "xdotool": + target_window_id = wait_window(env, parts[1], int(parts[2])) + else: + wait_process_alive(proc, int(parts[2])) + elif command == "motion": + if len(parts) != 3: + raise ReplayError("motion expects x y") + if args.input_backend == "xdotool": + if target_window_id is not None: + xdotool( + env, + "mousemove", + "--sync", + "--window", + target_window_id, + parts[1], + parts[2], + ) + else: + xdotool(env, "mousemove", "--sync", parts[1], parts[2]) + elif command == "button": + if len(parts) != 3: + raise ReplayError("button expects n press|release|click") + if args.input_backend == "xdotool": + if parts[2] == "click": + xdotool(env, "click", parts[1]) + elif parts[2] == "press": + xdotool(env, "mousedown", parts[1]) + elif parts[2] == "release": + xdotool(env, "mouseup", parts[1]) + else: + raise ReplayError( + "button action must be press, release, or click" + ) + elif command == "wheel": + if len(parts) != 3: + raise ReplayError("wheel expects up|down count") + button = "4" if parts[1] == "up" else "5" + if parts[1] not in ("up", "down"): + raise ReplayError("wheel direction must be up or down") + if args.input_backend == "xdotool": + for _ in range(int(parts[2])): + xdotool(env, "click", button) + time.sleep(0.05) + elif command == "key": + if len(parts) != 2: + raise ReplayError("key expects keysym") + if args.input_backend == "xdotool": + xdotool(env, "key", parts[1]) + elif command == "resize": + if len(parts) != 3: + raise ReplayError("resize expects W H") + width = int(parts[1]) + height = int(parts[2]) + if args.input_backend == "xdotool": + if target_window_id is None: + raise ReplayError("resize requires a prior wait-window") + xdotool( + env, + "windowsize", + "--sync", + target_window_id, + str(width), + str(height), + ) + elif command == "screenshot": + if len(parts) not in (2, 6): + raise ReplayError("screenshot expects: name [x y w h]") + name = parts[1] + shot = screenshot_dir / f"{name}.png" + bmp_path = (snapshot_dir / f"{name}.bmp") if snapshot_dir else None + region = args.screenshot_region + if len(parts) == 6: + region = tuple(int(v) for v in parts[2:6]) + if bmp_path is not None: + # The internal replay engine writes the BMP + # synchronously inside the X-client main thread, + # but the Python script and the libx11-compat + # replay thread share no clock -- the file may + # not exist yet when this step is reached, even + # though the in-process timeline schedules its + # snapshot at the same point. Poll up to the + # libx11-compat snapshot timeout (15s); the + # save itself is sub-millisecond but the main + # thread may be mid-reflow (e.g. right after a + # resize) and not drain the SDL_USEREVENT for + # several seconds. + wait_start = time.perf_counter() + deadline = time.time() + 16.0 + while time.time() < deadline: + if bmp_path.exists() and bmp_path.stat().st_size > 0: + break + time.sleep(0.05) + add_metric( + metrics, + lineno, + command, + name, + "snapshot_wait_ms", + duration_ms=f"{(time.perf_counter() - wait_start) * 1000.0:.3f}", + detail=str(bmp_path), + ) + if ( + bmp_path is not None + and bmp_path.exists() + and bmp_path.stat().st_size > 0 + ): + ensure_pil() + convert_start = time.perf_counter() + Image.open(bmp_path).save(shot) + add_metric( + metrics, + lineno, + command, + name, + "snapshot_convert_ms", + duration_ms=f"{(time.perf_counter() - convert_start) * 1000.0:.3f}", + detail=str(shot), + ) + elif bmp_path is not None: + # --in-process-snapshots was explicitly requested + # but no BMP showed up. Silently falling back to + # screencapture would re-enter the exact + # NSApp-stall path this flag was added to avoid, + # so a passing test would no longer prove the + # in-process path works (codex-flagged). Fail + # loudly with the app-log tail so the + # underlying snapshot failure is debuggable. + log_tail = "" + app_log_path = log_dir / f"{args.name}.log" + if app_log_path.exists(): + with app_log_path.open() as f: + tail_lines = f.readlines()[-20:] + log_tail = "".join(tail_lines) + raise ReplayError( + f"in-process snapshot to {bmp_path} did " + f"not produce a file within 5s; refusing " + f"to silently fall back to screencapture. " + f"App log tail:\n{log_tail}" + ) + else: + capture_start = time.perf_counter() + capture_screen(shot, env, args.screenshot_command, region) + add_metric( + metrics, + lineno, + command, + name, + "screenshot_capture_ms", + duration_ms=f"{(time.perf_counter() - capture_start) * 1000.0:.3f}", + detail=str(shot), + ) + screenshots[name] = shot + rows.append( + { + "status": "captured", + "relative_path": parts[1], + "screenshot": str(shot), + "detail": str(replay_path), + } + ) + elif command == "assert-image": + if len(parts) != 3: + raise ReplayError("assert-image expects screenshot rule") + if parts[1] not in screenshots: + raise ReplayError(f"unknown screenshot {parts[1]}") + rule_path = resolve_path( + parts[2], assertion_base, replay_path.parent, ROOT + ) + assert_start = time.perf_counter() + observations = assert_image( + rule_path, + screenshots[parts[1]], + screenshots, + assertion_base, + ) + add_metric( + metrics, + lineno, + command, + f"{parts[1]}:{parts[2]}", + "assert_image_ms", + duration_ms=f"{(time.perf_counter() - assert_start) * 1000.0:.3f}", + detail=str(rule_path), + ) + for observation in observations: + add_metric( + metrics, + lineno, + command, + parts[1], + observation["metric"], + value=observation["value"], + detail=observation.get("detail", ""), + ) + rows.append( + { + "status": "ok", + "relative_path": f"{parts[1]}:{parts[2]}", + "detail": "", + } + ) + elif command == "assert-exit": + if len(parts) != 2: + raise ReplayError("assert-exit expects status|any|running") + status = proc.poll() + expected = parts[1] + if expected == "running": + if status is not None: + raise ReplayError(f"process exited with status {status}") + elif expected == "any": + if status is None: + raise ReplayError("process is still running") + else: + if status is None: + raise ReplayError( + f"process still running, expected {expected}" + ) + if status != int(expected): + raise ReplayError( + f"process status {status}, expected {expected}" + ) + rows.append( + { + "status": "ok", + "relative_path": f"assert-exit:{expected}", + "detail": "", + } + ) + else: + raise ReplayError(f"unknown replay command {command}") + except ReplayError as error: + failed = True + detail = f"{replay_path}:{lineno}: {error}" + rows.append( + { + "status": "failed", + "relative_path": " ".join(parts), + "detail": detail, + } + ) + print(detail, file=sys.stderr) + finally: + add_metric( + metrics, + lineno, + command, + target, + "step_duration_ms", + duration_ms=f"{(time.perf_counter() - step_start) * 1000.0:.3f}", + ) + if failed: + break + finally: + if not args.leave_running: + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGTERM) + except OSError: + proc.terminate() + terminate_process(proc) + app_log.close() + if xvfb_proc is not None: + terminate_process(xvfb_proc) + + if not rows: + rows.append( + { + "status": "failed", + "relative_path": replay_path.name, + "detail": "replay produced no results", + } + ) + failed = True + write_results(out_root / "results.tsv", rows) + write_metrics(out_root / "metrics.tsv", metrics) + write_junit(out_root / "junit.xml", args.name, rows) + print(f"Wrote {out_root / 'results.tsv'}") + print(f"Wrote {out_root / 'metrics.tsv'}") + if args.render_stats is not None: + print(f"Wrote {args.render_stats}") + print(f"Wrote {out_root / 'junit.xml'}") + return 1 if failed else 0 + + +def main(): + parser = argparse.ArgumentParser( + description="Run deterministic UI replay scenarios and screenshot assertions." + ) + parser.add_argument("--name", required=True) + parser.add_argument("--app", required=True, type=Path) + parser.add_argument("--app-arg", action="append", default=[]) + parser.add_argument("--workdir", type=Path, default=ROOT) + parser.add_argument("--replay", required=True) + parser.add_argument("--out-root", type=Path, required=True) + parser.add_argument("--env", action="append", default=[]) + parser.add_argument("--display", default="121") + parser.add_argument("--geometry", default="1280x1024x24") + parser.add_argument("--xvfb", action="store_true") + parser.add_argument( + "--input-backend", + choices=("internal", "xdotool"), + default="internal", + help=( + "internal uses LIBX11_COMPAT_REPLAY inside the target process; " + "xdotool sends external X11 input" + ), + ) + parser.add_argument( + "--screenshot-command", + choices=("auto", "import", "gnome-screenshot", "screencapture"), + default="auto", + ) + parser.add_argument( + "--screenshot-region", + default=None, + help=( + "default region for screenshot command, as X,Y,W,H. Replay " + "scripts may override per-shot with `screenshot NAME X Y W H`. " + "Pair with the app's geometry argument so the captured pixels " + "always come from the target window rather than whatever the " + "host desktop happens to show on top." + ), + ) + parser.add_argument( + "--render-stats", + type=Path, + default=None, + help=( + "write libx11-compat renderer timing/readback stats to this TSV " + "by setting LIBX11_COMPAT_RENDER_STATS for the target process" + ), + ) + parser.add_argument("--leave-running", action="store_true") + parser.add_argument( + "--in-process-snapshots", + action="store_true", + help=( + "have libx11-compat's snapshot helper write the target " + "window surface to a BMP for each screenshot step, and " + "use those BMPs in place of screencapture. On macOS the " + "system screencapture deactivates the target NSApp " + "briefly while it grabs pixels, which stalls SDL's " + "event pump just long enough that synthetic input " + "queued by the in-process replay engine never gets " + "delivered. The in-process path side-steps that entirely." + ), + ) + args = parser.parse_args() + if args.screenshot_region is not None: + try: + parts = [int(v) for v in args.screenshot_region.split(",")] + except ValueError as error: + print( + f"--screenshot-region expects X,Y,W,H ints: {error}", + file=sys.stderr, + ) + return 2 + if len(parts) != 4: + print( + "--screenshot-region expects four comma-separated ints: X,Y,W,H", + file=sys.stderr, + ) + return 2 + args.screenshot_region = tuple(parts) + + try: + return run_replay(args) + except ReplayError as error: + print(f"run-ui-replay: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run-violawww-differential-tests.py b/scripts/run-violawww-differential-tests.py new file mode 100755 index 0000000..d8de37a --- /dev/null +++ b/scripts/run-violawww-differential-tests.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +import argparse +import os +import re +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUT_ROOT = ROOT / "build" / "violawww-differential" + + +def run(cmd, *, cwd=ROOT, env=None, input_text=None): + print("+", " ".join(str(c) for c in cmd), flush=True) + subprocess.run(cmd, cwd=cwd, env=env, input=input_text, text=True, check=True) + + +def rsync(src, dest, *, extra_args=None): + cmd = ["rsync", "-a", "--delete"] + if extra_args: + cmd.extend(extra_args) + cmd.extend([str(src), str(dest)]) + run(cmd) + + +def ssh(remote, script): + run(["ssh", remote, "sh", "-s"], input_text=script) + + +def q(value): + return shlex.quote(str(value)) + + +def parse_env_default(name, default): + value = os.environ.get(name) + if value is None or value == "": + return default + return value + + +def sync_repo(args): + remote_repo = f"{args.remote_root}/repo" + run(["ssh", args.remote, "mkdir", "-p", args.remote_root]) + rsync( + "./", + f"{args.remote}:{remote_repo}/", + extra_args=[ + "--exclude", + "/build/", + ], + ) + upstream_cache = ROOT / "build" / "upstream" / ".cache" + if upstream_cache.exists(): + run(["ssh", args.remote, "mkdir", "-p", f"{remote_repo}/build/upstream/.cache"]) + rsync( + f"{upstream_cache}/", f"{args.remote}:{remote_repo}/build/upstream/.cache/" + ) + return remote_repo + + +def remote_script(args, remote_repo): + clean_remote = "" + if args.clean: + clean_remote = ( + f"rm -rf {q(remote_repo + '/build/violawww-system-motif')} " + f"{q(remote_repo + '/build/violawww-system-out')} " + f"{q(remote_repo + '/build/violawww-system')} " + f"{q(args.remote_root + '/screens')} " + f"{q(args.remote_root + '/logs')} " + f"{q(args.remote_root + '/diff')}\n" + ) + + install_deps = "" + if args.install_deps: + install_deps = """ +if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + sudo apt-get update + sudo apt-get install -y --no-install-recommends \\ + autoconf automake bison build-essential ca-certificates git \\ + imagemagick libtool make pkg-config python3-pil rsync xauth xvfb \\ + xdotool libice-dev libsm-dev libx11-dev libxext-dev libxmu-dev libxpm-dev \\ + libxrender-dev libxt-dev +fi +""" + + return f""" +set -eu + +{install_deps} + +need() {{ + command -v "$1" >/dev/null 2>&1 || {{ + echo "missing required command: $1" >&2 + exit 127 + }} +}} + +need autoreconf +need gcc +need git +need import +need make +need pkg-config +need python3 +need rsync +need Xvfb +need xdotool +if command -v yacc >/dev/null 2>&1; then + yacc_bin=yacc +elif command -v bison >/dev/null 2>&1; then + yacc_bin="bison -y" +else + echo "missing required command: yacc or bison" >&2 + exit 127 +fi + +remote_root={q(args.remote_root)} +repo={q(remote_repo)} +system_build="$repo/build/violawww-system-motif" +system_out="$repo/build/violawww-system-out" +system_viola="$repo/build/violawww-system" +system_logs="$remote_root/logs/system" +compat_logs="$remote_root/logs/compat" +system_screens="$remote_root/screens/system" +compat_screens="$remote_root/screens/compat" +fixture="$remote_root/violawww-fixture.html" +display=:{q(args.display)} + +run_logged() {{ + log=$1 + shift + if "$@" >>"$log" 2>&1; then + return 0 + else + status=$? + echo "FAIL $*; see $log" >&2 + tail -60 "$log" >&2 || true + exit "$status" + fi +}} + +capture_vw() {{ + name=$1 + bin=$2 + workdir=$3 + libpath=$4 + log_dir=$5 + screen_dir=$6 + replay=$7 + input_backend=$8 + replay_out="$remote_root/replay-$name" + rm -rf "$replay_out" + mkdir -p "$log_dir" "$screen_dir" + python3 "$repo/scripts/run-ui-replay.py" \\ + --name "violawww-$name-scroll" \\ + --app "$bin" \\ + --app-arg "file:$fixture" \\ + --workdir "$workdir" \\ + --replay "$repo/tests/ui/replays/$replay" \\ + --out-root "$replay_out" \\ + --display {q(args.display)} \\ + --geometry {q(args.geometry)} \\ + --input-backend "$input_backend" \\ + --screenshot-command import \\ + --env DISPLAY="$display" \\ + --env HOME="$remote_root/home-$name" \\ + --env WWW_HOME="file:$fixture" \\ + --env LD_LIBRARY_PATH="$libpath${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}" + cp "$replay_out"/screens/*.png "$screen_dir"/ + cp "$replay_out"/results.tsv "$log_dir/results.tsv" + cp "$replay_out"/junit.xml "$log_dir/junit.xml" + cp "$replay_out"/logs/* "$log_dir"/ 2>/dev/null || true +}} + +{clean_remote} +mkdir -p "$system_build" "$system_out" "$system_viola" "$system_logs" \\ + "$compat_logs" "$system_screens" "$compat_screens" "$remote_root/home-system" \\ + "$remote_root/home-compat" "$remote_root/logs" + +cat >"$fixture" <<'EOF' + + + +ViolaWWW libX11 differential fixture + + +

ViolaWWW Differential

+

This local document exercises Motif chrome, Xlib drawing, text, links, lists, +forms, and table layout without depending on network access.

+ + + + + + + + +
APIExpected visible result
XDrawStringReadable proportional text
XFillRectangleCell backgrounds and borders
XDrawLineTable grid lines remain aligned while scrolling
XCopyAreaViewport updates do not leave stale glyphs
XClearAreaRows repaint cleanly after expose and resize
+

Scroll marker 01: this paragraph makes the page taller than the viewport.

+

Scroll marker 02: text should move after wheel input on system X11.

+

Scroll marker 03: text should move after wheel input on libx11-compat.

+

Scroll marker 04: repeated rows exercise clipping and repaint.

+

Scroll marker 05: repeated rows exercise clipping and repaint.

+

Scroll marker 06: repeated rows exercise clipping and repaint.

+

Scroll marker 07: repeated rows exercise clipping and repaint.

+

Scroll marker 08: repeated rows exercise clipping and repaint.

+

Scroll marker 09: repeated rows exercise clipping and repaint.

+

Scroll marker 10: repeated rows exercise clipping and repaint.

+

Scroll marker 11: repeated rows exercise clipping and repaint.

+

Scroll marker 12: repeated rows exercise clipping and repaint.

+

Scroll marker 13: repeated rows exercise clipping and repaint.

+

Scroll marker 14: repeated rows exercise clipping and repaint.

+

Scroll marker 15: repeated rows exercise clipping and repaint.

+
+ + +
+ + +EOF + +cd "$repo" +make -j{q(args.jobs)} CC=gcc violawww + +motif_src="$repo/build/upstream/motif-src" +cd "$system_build" +if [ ! -f .configure-stamp ]; then + : >"$remote_root/logs/system-motif-configure.log" + run_logged "$remote_root/logs/system-motif-configure.log" env \\ + CPP="gcc -E" \\ + CFLAGS="-g -O0 -include stdlib.h" \\ + YACC="$yacc_bin" \\ + "$motif_src/configure" \\ + --prefix="$system_out/motif-install" \\ + --disable-glw \\ + --disable-tests \\ + --without-xft \\ + --with-jpeg=no \\ + --with-png=no \\ + --with-xrandr=no \\ + --with-xrender=no \\ + --with-xcursor=no \\ + --with-xinerama=no + touch .configure-stamp +fi +: >"$remote_root/logs/system-motif-build.log" +run_logged "$remote_root/logs/system-motif-build.log" make -C config +run_logged "$remote_root/logs/system-motif-build.log" make -C lib/Xm CFLAGS="-g -O0 -include stdlib.h" +run_logged "$remote_root/logs/system-motif-build.log" make -C lib/Mrm CFLAGS="-g -O0 -include stdlib.h" + +rm -rf "$system_viola/source" +mkdir -p "$system_viola/source" +tar --exclude .git --exclude '*.o' --exclude '*.d' --exclude '*.a' \\ + --exclude src/vw/vw --exclude src/viola/viola --exclude vplot_dir/vplot \\ + -cf - -C "$repo/build/upstream/violawww-src" . | tar -xf - -C "$system_viola/source" +for patch_file in "$repo"/compat/violawww-patches/*.patch; do + [ -e "$patch_file" ] || continue + patch -d "$system_viola/source" -p1 < "$patch_file" +done + +x11_cflags=$(pkg-config --cflags x11 xext xmu xt sm ice xrender xpm 2>/dev/null || true) +x11_libs=$(pkg-config --libs x11 xext xmu xt sm ice xrender xpm 2>/dev/null || \\ + printf '%s\\n' "-lXext -lXmu -lXt -lSM -lICE -lX11 -lXrender -lXpm") +motif_cflags="-I$motif_src/lib -I$system_build/lib" +motif_libs="-L$system_build/lib/Xm/.libs -L$system_build/lib/Mrm/.libs" +: "${{x11_cflags:=$motif_cflags}}" +x11_cflags="$motif_cflags $x11_cflags" +: >"$remote_root/logs/system-violawww-build.log" +run_logged "$remote_root/logs/system-violawww-build.log" \\ + make -C "$system_viola/source" \\ + CC=gcc \\ + OPENMOTIF_PREFIX="$system_out/motif-install" \\ + CFLAGS="-O0 -g -std=gnu17 -funsigned-char -DVIOLA_LINUX -DNO_ALLOCA -Wno-everything" \\ + CFLAGS_LIBS="-O0 -g -funsigned-char -DVIOLA_LINUX -DNO_ALLOCA -Wno-everything" \\ + X11_CFLAGS="$x11_cflags" \\ + X11_LIBS="$x11_libs" \\ + LDFLAGS="$motif_libs -Wl,-rpath,$system_build/lib/Xm/.libs -Wl,-rpath,$system_build/lib/Mrm/.libs" \\ + LIBS="-lXm $x11_libs -lm" \\ + all + +rm -f "/tmp/.X{q(args.display)}-lock" +Xvfb "$display" -screen 0 {q(args.geometry)} >"$remote_root/xvfb.log" 2>&1 & +xvfb_pid=$! +trap 'kill "$xvfb_pid" >/dev/null 2>&1 || true' EXIT +sleep 1 + +capture_vw system \\ + "$system_viola/source/src/vw/vw" \\ + "$system_viola/source" \\ + "$system_build/lib/Xm/.libs:$system_build/lib/Mrm/.libs" \\ + "$system_logs" \\ + "$system_screens" \\ + violawww-scroll-system.replay \\ + xdotool + +capture_vw compat \\ + "$repo/build/violawww/source/src/vw/vw" \\ + "$repo/build/violawww/source" \\ + "$repo/build:$repo/build/motif-install/lib" \\ + "$compat_logs" \\ + "$compat_screens" \\ + violawww-scroll-system.replay \\ + internal + +""" + + +def remote_compare_script(args): + return f""" +set -eu +remote_root={q(args.remote_root)} +repo="$remote_root/repo" +python3 "$repo/scripts/compare-motif-reference.py" \\ + --skip-local \\ + --skip-remote \\ + --local-dir "$remote_root/screens/compat" \\ + --ref-dir "$remote_root/screens/system" \\ + --diff-dir "$remote_root/diff" \\ + --report "$remote_root/report.tsv" \\ + --junit "$remote_root/junit.xml" \\ + --local-results "$remote_root/logs/compat/results.tsv" \\ + --ref-results "$remote_root/logs/system/results.tsv" \\ + --mae-threshold {q(args.mae_threshold)} \\ + --changed-threshold {q(args.changed_threshold)} \\ + --top {q(args.top)} +""" + + +def fetch_results(args, *, fetch_remote_compare=False): + out_root = args.out_root + system_dir = out_root / "system" + compat_dir = out_root / "compat" + log_dir = out_root / "logs" + diff_dir = out_root / "diff" + out_root.mkdir(parents=True, exist_ok=True) + for path in (system_dir, compat_dir, log_dir, diff_dir): + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True) + + rsync(f"{args.remote}:{args.remote_root}/screens/system/", system_dir) + rsync(f"{args.remote}:{args.remote_root}/screens/compat/", compat_dir) + rsync(f"{args.remote}:{args.remote_root}/logs/", log_dir) + if fetch_remote_compare: + rsync(f"{args.remote}:{args.remote_root}/diff/", diff_dir) + rsync(f"{args.remote}:{args.remote_root}/report.tsv", out_root / "report.tsv") + rsync(f"{args.remote}:{args.remote_root}/junit.xml", out_root / "junit.xml") + return system_dir, compat_dir, out_root + + +def compare(args, system_dir, compat_dir, out_root): + cmd = [ + sys.executable, + "scripts/compare-motif-reference.py", + "--skip-local", + "--skip-remote", + "--local-dir", + str(compat_dir), + "--ref-dir", + str(system_dir), + "--diff-dir", + str(out_root / "diff"), + "--report", + str(out_root / "report.tsv"), + "--junit", + str(out_root / "junit.xml"), + "--local-results", + str(out_root / "logs" / "compat" / "results.tsv"), + "--ref-results", + str(out_root / "logs" / "system" / "results.tsv"), + "--mae-threshold", + str(args.mae_threshold), + "--changed-threshold", + str(args.changed_threshold), + "--top", + str(args.top), + ] + run(cmd) + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Build ViolaWWW on a Linux SSH host against system libX11 and " + "libx11-compat, capture screenshots under Xvfb, and compare output." + ) + ) + parser.add_argument( + "--remote", + default=parse_env_default("VIOLAWWW_DIFF_REMOTE", "node11"), + ) + parser.add_argument( + "--remote-root", + default=parse_env_default( + "VIOLAWWW_DIFF_REMOTE_ROOT", + "/tmp/libx11-compat-violawww-differential", + ), + ) + parser.add_argument( + "--display", + default=parse_env_default("VIOLAWWW_DIFF_DISPLAY", "120"), + ) + parser.add_argument( + "--geometry", + default=parse_env_default("VIOLAWWW_DIFF_GEOMETRY", "1280x1024x24"), + ) + parser.add_argument( + "--jobs", + default=parse_env_default("VIOLAWWW_DIFF_JOBS", os.environ.get("JOBS", "1")), + ) + parser.add_argument( + "--seconds", + default=parse_env_default("VIOLAWWW_DIFF_SECONDS", "5"), + ) + parser.add_argument("--clean", action="store_true") + parser.add_argument( + "--install-deps", + action="store_true", + help="install minimal Ubuntu packages on the remote via sudo apt-get", + ) + parser.add_argument( + "--mae-threshold", + type=float, + default=float(parse_env_default("VIOLAWWW_DIFF_MAE_THRESHOLD", "0.12")), + ) + parser.add_argument( + "--changed-threshold", + type=float, + default=float(parse_env_default("VIOLAWWW_DIFF_CHANGED_THRESHOLD", "0.35")), + ) + parser.add_argument( + "--top", + type=int, + default=int(parse_env_default("VIOLAWWW_DIFF_TOP", "12")), + ) + parser.add_argument( + "--out-root", + type=Path, + default=Path(parse_env_default("VIOLAWWW_DIFF_OUT_ROOT", DEFAULT_OUT_ROOT)), + help="local artifact directory for synced screenshots, diffs, TSV, and JUnit", + ) + parser.add_argument( + "--compare-location", + choices=("remote", "local"), + default=parse_env_default("VIOLAWWW_DIFF_COMPARE_LOCATION", "remote"), + ) + args = parser.parse_args() + + if not re.fullmatch(r"\d+", args.display): + parser.error("--display must be a numeric X display index") + if not re.fullmatch(r"\d+", str(args.jobs)): + parser.error("--jobs must be a positive integer") + if int(args.jobs) <= 0: + parser.error("--jobs must be a positive integer") + + remote_repo = sync_repo(args) + remote_status = 0 + compare_status = 0 + fetch_status = 0 + try: + ssh(args.remote, remote_script(args, remote_repo)) + except subprocess.CalledProcessError as error: + remote_status = error.returncode + + if args.compare_location == "remote" and not remote_status: + try: + ssh(args.remote, remote_compare_script(args)) + except subprocess.CalledProcessError as error: + compare_status = error.returncode + + # Catch rsync failures here so an unrelated transfer hiccup can't + # mask the original remote/compare failure on the exit path below. + try: + system_dir, compat_dir, out_root = fetch_results( + args, + fetch_remote_compare=args.compare_location == "remote" + and not remote_status, + ) + except subprocess.CalledProcessError as error: + fetch_status = error.returncode + system_dir = compat_dir = out_root = None + print( + f"warning: result fetch failed (exit {fetch_status})", + file=sys.stderr, + ) + + if args.compare_location == "local" and system_dir is not None: + try: + compare(args, system_dir, compat_dir, out_root) + except subprocess.CalledProcessError as error: + compare_status = error.returncode + + if remote_status: + sys.exit(remote_status) + if compare_status: + sys.exit(compare_status) + if fetch_status: + sys.exit(fetch_status) + + +if __name__ == "__main__": + main() diff --git a/scripts/sync-upstream-headers.py b/scripts/sync-upstream-headers.py index 150bee4..0523876 100644 --- a/scripts/sync-upstream-headers.py +++ b/scripts/sync-upstream-headers.py @@ -52,20 +52,20 @@ { "name": "libX11", "version": "libX11-1.8.13", - "url": "https://gitlab.freedesktop.org/xorg/lib/libx11/-/archive/libX11-1.8.13/libx11-libX11-1.8.13.tar.gz", - "sha256": "d210291f5cd974e5029cce4bde0fc1b8bfc0ce88b8530ea6ba4b1fcf141506ab", + "url": "https://xorg.freedesktop.org/archive/individual/lib/libX11-1.8.13.tar.xz", + "sha256": "69606f485c2c07c14ef64f75b7bb326d48587af33795d9ab3e607c0b5f94f11c", }, { "name": "xorgproto", "version": "xorgproto-2025.1", - "url": "https://gitlab.freedesktop.org/xorg/proto/xorgproto/-/archive/xorgproto-2025.1/xorgproto-xorgproto-2025.1.tar.gz", - "sha256": "473e9d4608d9c1c3f42346746deb06b3e0d440349422d0857ef0989e17d7e03d", + "url": "https://xorg.freedesktop.org/archive/individual/proto/xorgproto-2025.1.tar.xz", + "sha256": "56898c716c0578df8a2d828c9c3e5c528277705c0484381a81960fe1a67668e8", }, { "name": "libXt", "version": "libXt-1.3.1", - "url": "https://gitlab.freedesktop.org/xorg/lib/libxt/-/archive/libXt-1.3.1/libxt-libXt-1.3.1.tar.gz", - "sha256": "07f71c105a979fe570e5b985dfc58ad512973aaa923c29f11b5009c302f9a76e", + "url": "https://xorg.freedesktop.org/archive/individual/lib/libXt-1.3.1.tar.xz", + "sha256": "e0a774b33324f4d4c05b199ea45050f87206586d81655f8bef4dba434d931288", # libXt's src/ goes to its own staging dir so it does not collide # with the libX11 src/ slice (different headers, different build # flags). All .c files in src/ are taken so the Makefile picks them @@ -86,16 +86,16 @@ { "name": "libXpm", "version": "libXpm-3.5.19", - "url": "https://gitlab.freedesktop.org/xorg/lib/libxpm/-/archive/libXpm-3.5.19/libxpm-libXpm-3.5.19.tar.gz", - "sha256": "cccff8ec2210476c698d746743aa75504263ce7727089fb832efaeb971ebefa7", + "url": "https://xorg.freedesktop.org/archive/individual/lib/libXpm-3.5.19.tar.xz", + "sha256": "ad3576d689221a39dc728f0e0dc02ca7bb6a0d724c9a77fd1bfa1e9af83be900", "src_subdir": "src-libXpm", "src_take_all": True, }, { "name": "libXmu", "version": "libXmu-1.3.1", - "url": "https://gitlab.freedesktop.org/xorg/lib/libxmu/-/archive/libXmu-1.3.1/libxmu-libXmu-1.3.1.tar.gz", - "sha256": "a38bff41f609e2ea887c05fb4a31e926a03ba1d69ddda9423682e198839f2355", + "url": "https://xorg.freedesktop.org/archive/individual/lib/libXmu-1.3.1.tar.xz", + "sha256": "81a99e94c4501e81c427cbaa4a11748b584933e94b7a156830c3621256857bc4", # Pulled in for clients/mwm and Motif's clients/ tree. The # tarball ships its public surface under include/X11/Xmu/ which # the existing extractor already routes into the staged X11 @@ -234,6 +234,187 @@ "_XtShellGetCoordinates(Widget widget, Position *x, Position *y)\n", ), ), + # Keep one spare NULL callback record after every internal list. libXt's + # public XtCallbackList form is NULL-terminated, and Motif resource + # converters can hand lists produced from these internals back through + # _XtCompileCallbackList(). Without the sentinel, sanitizer builds read + # past single-callback lists while creating widgets. Every producer + # (_XtAddCallback, AddCallbacks, _XtRemoveCallback, XtRemoveCallbacks, + # _XtCompileCallbackList) must allocate count+1 records and write the + # NULL terminator, or removal/compile paths will drop the spare slot. + "Callback.c": ( + ( + " sizeof(XtCallbackRec) * (size_t) (count +\n" + " 1)));\n" + " (void) memmove((char *) ToList(icl), (char *) ToList(*callbacks),\n", + " sizeof(XtCallbackRec) * (size_t) (count +\n" + " 2)));\n" + " (void) memmove((char *) ToList(icl), (char *) ToList(*callbacks),\n", + ), + ( + " sizeof(XtCallbackRec) *\n" + " (size_t) (count + 1)));\n" + " }\n" + " *callbacks = icl;\n", + " sizeof(XtCallbackRec) *\n" + " (size_t) (count + 2)));\n" + " }\n" + " *callbacks = icl;\n", + ), + ( + " cl = ToList(icl) + count;\n" + " cl->callback = callback;\n" + " cl->closure = closure;\n" + "} /* _XtAddCallback */\n", + " cl = ToList(icl) + count;\n" + " cl->callback = callback;\n" + " cl->closure = closure;\n" + " (++cl)->callback = (XtCallbackProc) NULL;\n" + " cl->closure = NULL;\n" + "} /* _XtAddCallback */\n", + ), + ( + " sizeof(XtCallbackRec) * (size_t) (i + j)));\n" + " (void) memmove((char *) ToList(*callbacks), (char *) ToList(icl),\n", + " sizeof(XtCallbackRec) * (size_t) (i + j + 1)));\n" + " (void) memmove((char *) ToList(*callbacks), (char *) ToList(icl),\n", + ), + ( + " sizeof(XtCallbackRec)\n" + " * (size_t) (i + j)));\n" + " }\n" + " *callbacks = icl;\n", + " sizeof(XtCallbackRec)\n" + " * (size_t) (i + j + 1)));\n" + " }\n" + " *callbacks = icl;\n", + ), + ( + " for (cl = ToList(icl) + i; --j >= 0;)\n" + " *cl++ = *newcallbacks++;\n" + "} /* AddCallbacks */\n", + " for (cl = ToList(icl) + i; --j >= 0;)\n" + " *cl++ = *newcallbacks++;\n" + " cl->callback = (XtCallbackProc) NULL;\n" + " cl->closure = NULL;\n" + "} /* AddCallbacks */\n", + ), + # _XtRemoveCallback (call_state branch): grow allocation by one and + # write the sentinel after both copy loops finish populating ncl. + ( + " icl = (InternalCallbackList)\n" + " __XtMalloc((Cardinal) (sizeof(InternalCallbackRec) +\n" + " sizeof(XtCallbackRec) *\n" + " (size_t) (i + j)));\n" + " icl->count = (unsigned short) (i + j);\n" + " icl->is_padded = 0;\n" + " icl->call_state = 0;\n" + " ncl = ToList(icl);\n" + " while (--j >= 0)\n" + " *ncl++ = *ocl++;\n" + " while (--i >= 0)\n" + " *ncl++ = *++cl;\n" + " *callbacks = icl;\n", + " icl = (InternalCallbackList)\n" + " __XtMalloc((Cardinal) (sizeof(InternalCallbackRec) +\n" + " sizeof(XtCallbackRec) *\n" + " (size_t) (i + j + 1)));\n" + " icl->count = (unsigned short) (i + j);\n" + " icl->is_padded = 0;\n" + " icl->call_state = 0;\n" + " ncl = ToList(icl);\n" + " while (--j >= 0)\n" + " *ncl++ = *ocl++;\n" + " while (--i >= 0)\n" + " *ncl++ = *++cl;\n" + " ncl->callback = (XtCallbackProc) NULL;\n" + " ncl->closure = NULL;\n" + " *callbacks = icl;\n", + ), + # _XtRemoveCallback (non-call_state branch): grow XtRealloc by one + # and re-anchor the sentinel at the new tail. + ( + " if (--icl->count) {\n" + " ncl = cl + 1;\n" + " while (--i >= 0)\n" + " *cl++ = *ncl++;\n" + " icl = (InternalCallbackList)\n" + " XtRealloc((char *) icl,\n" + " (Cardinal) (sizeof(InternalCallbackRec)\n" + " +\n" + " sizeof(XtCallbackRec) *\n" + " icl->count));\n" + " icl->is_padded = 0;\n" + " *callbacks = icl;\n", + " if (--icl->count) {\n" + " ncl = cl + 1;\n" + " while (--i >= 0)\n" + " *cl++ = *ncl++;\n" + " icl = (InternalCallbackList)\n" + " XtRealloc((char *) icl,\n" + " (Cardinal) (sizeof(InternalCallbackRec)\n" + " +\n" + " sizeof(XtCallbackRec) *\n" + " (size_t) (icl->count + 1)));\n" + " icl->is_padded = 0;\n" + " cl = ToList(icl) + icl->count;\n" + " cl->callback = (XtCallbackProc) NULL;\n" + " cl->closure = NULL;\n" + " *callbacks = icl;\n", + ), + # XtRemoveCallbacks: same idea — grow by one and write sentinel. + ( + " if (icl->count) {\n" + " icl = (InternalCallbackList)\n" + " XtRealloc((char *) icl, (Cardinal) (sizeof(InternalCallbackRec) +\n" + " sizeof(XtCallbackRec) *\n" + " icl->count));\n" + " icl->is_padded = 0;\n" + " *callbacks = icl;\n" + " }\n", + " if (icl->count) {\n" + " icl = (InternalCallbackList)\n" + " XtRealloc((char *) icl, (Cardinal) (sizeof(InternalCallbackRec) +\n" + " sizeof(XtCallbackRec) *\n" + " (size_t) (icl->count + 1)));\n" + " icl->is_padded = 0;\n" + " ccl = ToList(icl) + icl->count;\n" + " ccl->callback = (XtCallbackProc) NULL;\n" + " ccl->closure = NULL;\n" + " *callbacks = icl;\n" + " }\n", + ), + # _XtCompileCallbackList: allocate n+1 records and stamp the + # terminator after the copy loop. + ( + " callbacks =\n" + " (InternalCallbackList)\n" + " __XtMalloc((Cardinal)\n" + " (sizeof(InternalCallbackRec) +\n" + " sizeof(XtCallbackRec) * (size_t) n));\n" + " callbacks->count = (unsigned short) n;\n" + " callbacks->is_padded = 0;\n" + " callbacks->call_state = 0;\n" + " cl = ToList(callbacks);\n" + " while (--n >= 0)\n" + " *cl++ = *xtcallbacks++;\n" + " return (callbacks);\n", + " callbacks =\n" + " (InternalCallbackList)\n" + " __XtMalloc((Cardinal)\n" + " (sizeof(InternalCallbackRec) +\n" + " sizeof(XtCallbackRec) * (size_t) (n + 1)));\n" + " callbacks->count = (unsigned short) n;\n" + " callbacks->is_padded = 0;\n" + " callbacks->call_state = 0;\n" + " cl = ToList(callbacks);\n" + " while (--n >= 0)\n" + " *cl++ = *xtcallbacks++;\n" + " cl->callback = (XtCallbackProc) NULL;\n" + " cl->closure = NULL;\n" + " return (callbacks);\n", + ), + ), } # Guard against typos when bumping versions: every URL must contain its tag. diff --git a/src/colors.h b/src/colors.h index 45fb3b5..68f597a 100644 --- a/src/colors.h +++ b/src/colors.h @@ -33,6 +33,20 @@ #define GET_BLUE_FROM_COLOR(color) ((Uint8) ((color >> BLUE_SHIFT) & 0xFF)) #define GET_ALPHA_FROM_COLOR(color) ((Uint8) ((color >> ALPHA_SHIFT) & 0xFF)) +/* Core X11 has no notion of alpha: pixel values written through Xlib are + * opaque by definition and the upper byte is conventionally zero. SDL2 + * textures are RGBA, so a literal copy renders X11 pixels as fully + * transparent. Promote alpha == 0 to 0xFF to preserve the legacy + * contract. Trade-off: callers that intentionally pass an ARGB pixel + * value with alpha == 0 (a non-core Xrender/Composite usage) lose the + * transparency. Use the XRender path when alpha is meaningful. */ +static inline unsigned long colorWithOpaqueDefault(unsigned long color) +{ + if ((color & (0xFFul << ALPHA_SHIFT)) == 0) + return color | (0xFFul << ALPHA_SHIFT); + return color; +} + extern Colormap GREY_SCALE_COLORMAP; extern Colormap REAL_COLOR_COLORMAP; diff --git a/src/display.c b/src/display.c index 15f3f04..621079f 100644 --- a/src/display.c +++ b/src/display.c @@ -6,9 +6,11 @@ #include "window.h" #include "errors.h" #include "events.h" +#include "replay.h" #include "colors.h" #include "drawing.h" #include "display.h" +#include "gc.h" #include "atoms.h" #include "visual.h" #include "font.h" @@ -66,13 +68,27 @@ int XCloseDisplay(Display *display) screen->default_gc = NULL; } } - /* Release this Display's per-Display error.c state before SDL_Quit: - * the side-table teardown touches SDL_mutex APIs and must run while - * the SDL subsystem is still up. */ + /* Teardown order before SDL_Quit: + * releaseLastRequestCode -> replayStop -> destroyScreenWindow + * -> closeEventPipe -> SDL_Quit. + * All four touch SDL primitives (mutexes, timers, the event filter) + * that must still exist. replayStop precedes closeEventPipe so the + * worker cannot push XTest events through a queue with no filter + * behind it. destroyScreenWindow precedes closeEventPipe because + * destroyWindow recurses into discardQueuedEventsForWindow() and + * postEvent(DestroyNotify), both of which take per-display event + * mutexes that closeEventPipe destroys. The numDisplaysOpen guard + * scopes replay/screen teardown to the last close so secondary + * displays opened by Motif/Xt probes do not tear down shared + * state. */ releaseLastRequestCode(display); if (numDisplaysOpen == 1) { + replayStop(); freeImageStorage(); destroyScreenWindow(display); + } + closeEventPipe(display); + if (numDisplaysOpen == 1) { freeAtomStorage(); resetSelectionAtomCache(); freeFontStorage(); @@ -95,7 +111,6 @@ int XCloseDisplay(Display *display) } free(GET_DISPLAY(display)->screens); } - closeEventPipe(display); free(display); return 0; } @@ -123,6 +138,12 @@ Display *XOpenDisplay(_Xconst char *display_name) if (!SDL_WasInit(SDL_INIT_VIDEO)) { SDL_SetMainReady(); SDL_SetHint(SDL_HINT_VIDEO_X11_XKB, "0"); + /* On macOS, the click that activates a background window is + * consumed by activation and never seen by the app. Click-through + * routes that first click to the app as a real button event, + * matching X11 semantics. Has no effect on Accessibility/TCC + * gating of synthetic input (cliclick, CGEvent). */ + SDL_SetHint(SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH, "1"); if (SDL_Init(SDL_INIT_VIDEO) == -1) { LOG("Failed to initialize SDL: %s\n", SDL_GetError()); free(display); @@ -246,11 +267,12 @@ Display *XOpenDisplay(_Xconst char *display_name) depthIndex++) { screen->depths[depthIndex].depth = supportedDepths[depthIndex]; } - /* ARGB pixel encoding matches src/colors.h shifts - * (A=24, R=16, G=8, B=0). + /* Default pixels are 24-bit TrueColor values, not full internal + * ARGB draw colors. Some legacy clients index tables sized by + * 1 << DefaultDepth using BlackPixel/WhitePixel. */ - screen->white_pixel = 0xFFFFFFFF; - screen->black_pixel = 0xFF000000; + screen->white_pixel = 0x00FFFFFF; + screen->black_pixel = 0x00000000; screen->cmap = REAL_COLOR_COLORMAP; screen->min_maps = 1; screen->max_maps = 1; @@ -273,6 +295,10 @@ Display *XOpenDisplay(_Xconst char *display_name) XCloseDisplay(display); return NULL; } + GraphicContext *defaultGc = + GET_GC(display->screens[screenIndex].default_gc); + defaultGc->foreground = display->screens[screenIndex].black_pixel; + defaultGc->background = display->screens[screenIndex].white_pixel; } if (numDisplaysOpen == 1) { @@ -319,7 +345,8 @@ int XGrabServer(Display *display) { // https://tronche.com/gui/x/xlib/window-and-session-manager/XGrabServer.html SET_X_SERVER_REQUEST(display, X_GrabServer); - WARN_UNIMPLEMENTED; + /* No-op: this backend has no separate X server or competing clients to + * exclude while a client updates global state. */ return 1; } @@ -327,7 +354,6 @@ int XUngrabServer(Display *display) { // https://tronche.com/gui/x/xlib/window-and-session-manager/XUngrabServer.html SET_X_SERVER_REQUEST(display, X_UngrabServer); - WARN_UNIMPLEMENTED; return 1; } diff --git a/src/display.h b/src/display.h index c669116..016a6a0 100644 --- a/src/display.h +++ b/src/display.h @@ -4,7 +4,22 @@ #include "resource-types.h" +#ifdef DEBUG_LIBX11_COMPAT +#include +#include +#define GET_DISPLAY(display) \ + (__extension__({ \ + Display *_gd_d = (display); \ + if (!_gd_d) { \ + fprintf(stderr, "%s:%d: GET_DISPLAY(NULL) in debug build\n", \ + __FILE__, __LINE__); \ + abort(); \ + } \ + (_XPrivDisplay) _gd_d; \ + })) +#else #define GET_DISPLAY(display) ((_XPrivDisplay) (display)) +#endif void setLastRequestCode(Display *display, unsigned char requestCode); unsigned char getLastRequestCode(Display *display); diff --git a/src/drawing.c b/src/drawing.c index 98f7ac7..90e40b6 100644 --- a/src/drawing.c +++ b/src/drawing.c @@ -1,3 +1,4 @@ +#include #include #include #include "X11/Xlib.h" @@ -12,7 +13,9 @@ #include "events.h" #include "path/raster.h" #include +#include #include +#include #ifndef M_PI #define M_PI 3.14159265358979323846 @@ -23,6 +26,7 @@ static SDL_atomic_t presentWakeTimerPending = {False}; static SDL_atomic_t presentWakeEventType = {-1}; static unsigned long opaqueColorIfAlphaUnset(unsigned long color); +static Bool getDrawableSize(Drawable drawable, int *width, int *height); static void resolveWindowBackground(Window window, Pixmap *backgroundPixmap, unsigned long *backgroundColor); @@ -41,6 +45,38 @@ typedef struct ShapeMaskView { static Bool resolveShapeMasks(const WindowStruct *window, ShapeMaskView *out); static Bool pixelInsideShape(const ShapeMaskView *view, int64_t wx, int64_t wy); +static uint64_t monotonicNowNs(void) +{ + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) + return 0; + return ((uint64_t) now.tv_sec * 1000000000ULL) + (uint64_t) now.tv_nsec; +} + +static double nsToMs(uint64_t ns) +{ + return (double) ns / 1000000.0; +} + +static FILE *renderStatsFile(void) +{ + static Bool initialized = False; + static FILE *file = NULL; + if (!initialized) { + initialized = True; + const char *path = getenv("LIBX11_COMPAT_RENDER_STATS"); + if (path && *path) { + file = fopen(path, "w"); + if (file) { + setvbuf(file, NULL, _IOLBF, 0); + fprintf(file, + "event\telapsed_ms\treadback_ms\twindows\tpixels\n"); + } + } + } + return file; +} + static Uint32 presentWakeTimerCallback(Uint32 interval, void *param) { (void) interval; @@ -53,6 +89,7 @@ static Uint32 presentWakeTimerCallback(Uint32 interval, void *param) SDL_Event event; SDL_zero(event); event.type = (Uint32) eventType; + event.user.code = PRESENT_EVENT_CODE; if (SDL_PushEvent(&event) == 1) SDL_AtomicSet(&presentWakePending, True); return 0; @@ -80,6 +117,10 @@ void drawWindowDataToScreen() if (!screen) return; + uint64_t totalStart = monotonicNowNs(); + uint64_t readbackNs = 0; + uint64_t presentedPixels = 0; + size_t presentedWindows = 0; Window *children = GET_CHILDREN(SCREEN_WINDOW); SDL_Texture *prevTarget = SDL_GetRenderTarget(screen); Bool screenTargetMutated = False; @@ -152,6 +193,7 @@ void drawWindowDataToScreen() .h = rh, }; int readRc = -1; + uint64_t readStart = monotonicNowNs(); if (winFmt == readFmt) { /* Direct readback into the window's framebuffer. Saves one * full-image memcpy on every present. @@ -173,10 +215,13 @@ void drawWindowDataToScreen() SDL_FreeSurface(staging); } } + readbackNs += monotonicNowNs() - readStart; if (readRc == 0) { SDL_UpdateWindowSurface(child->sdlWindow); child->needsPresent = False; child->hasPresented = True; + presentedWindows++; + presentedPixels += (uint64_t) rw * (uint64_t) rh; } else { LOG("SDL_RenderReadPixels failed in %s: %s\n", __func__, SDL_GetError()); @@ -190,6 +235,13 @@ void drawWindowDataToScreen() #ifdef DEBUG_WINDOWS printWindowsHierarchy(); #endif + FILE *stats = renderStatsFile(); + if (stats) { + uint64_t totalEnd = monotonicNowNs(); + fprintf(stats, "drawWindowDataToScreen\t%.3f\t%.3f\t%zu\t%" PRIu64 "\n", + nsToMs(totalEnd - totalStart), nsToMs(readbackNs), + presentedWindows, presentedPixels); + } } void markWindowNeedsPresent(Window window) @@ -420,11 +472,31 @@ SDL_Surface *getRenderSurfaceRect(SDL_Renderer *renderer, }; size_t dstOffsetX = (size_t) (clipped.x - source->x); size_t dstOffsetY = (size_t) (clipped.y - source->y); + SDL_Rect targetReadRect = readRect; + if (targetReadRect.x < 0) { + int clippedPixels = -targetReadRect.x; + if (clippedPixels >= targetReadRect.w) + return surface; + targetReadRect.x = 0; + targetReadRect.w -= clippedPixels; + dstOffsetX += (size_t) clippedPixels; + } + if (targetReadRect.y < 0) { + int clippedPixels = -targetReadRect.y; + if (clippedPixels >= targetReadRect.h) + return surface; + targetReadRect.y = 0; + targetReadRect.h -= clippedPixels; + dstOffsetY += (size_t) clippedPixels; + } + if (targetReadRect.w <= 0 || targetReadRect.h <= 0) + return surface; Uint8 *dstPixels = (Uint8 *) surface->pixels + dstOffsetY * (size_t) surface->pitch + dstOffsetX * (SDL_SURFACE_DEPTH / 8); - if (SDL_RenderReadPixels(renderer, &readRect, SDL_PIXELFORMAT_RGBA8888, - dstPixels, surface->pitch) != 0) { + if (SDL_RenderReadPixels(renderer, &targetReadRect, + SDL_PIXELFORMAT_RGBA8888, dstPixels, + surface->pitch) != 0) { LOG("SDL_RenderReadPixels failed in %s: %s\n", __func__, SDL_GetError()); SDL_FreeSurface(surface); @@ -553,6 +625,46 @@ int getGcClipIterationCount(GC gc) return gContext->clipRectCount; } +/* Centralize the graphics-exposures lookup so every XCopyArea exit + * path tolerates a NULL gc the same way the clip helpers already do. */ +static Bool gcWantsGraphicsExposures(GC gc) +{ + if (!gc) + return False; + GraphicContext *gContext = GET_GC(gc); + return gContext ? gContext->graphicsExposures : False; +} + +static Bool rectInsideDrawable(Drawable drawable, const SDL_Rect *rect) +{ + if (!rect) + return False; + int width = 0; + int height = 0; + if (!getDrawableSize(drawable, &width, &height)) + return False; + return rect->x >= 0 && rect->y >= 0 && rect->w >= 0 && rect->h >= 0 && + rect->x <= width && rect->y <= height && + rect->w <= width - rect->x && rect->h <= height - rect->y; +} + +static void postCopyAreaExposure(Display *display, + Drawable src, + Drawable dest, + GC gc, + const SDL_Rect *srcRect, + const SDL_Rect *destRect) +{ + if (!gcWantsGraphicsExposures(gc)) + return; + if (rectInsideDrawable(src, srcRect)) { + postEvent(display, dest, NoExpose, X_CopyArea, 0); + return; + } + postEvent(display, dest, GraphicsExpose, (SDL_Rect *) destRect, (size_t) 1, + X_CopyArea, 0); +} + void setRendererDrawableClip(SDL_Renderer *renderer, const SDL_Rect *clip) { if (!renderer || !clip) { @@ -631,6 +743,14 @@ void applySdlDrawState(SDL_Renderer *renderer, if (!renderer) return; + /* Core X11 pixels have no alpha; the upper byte is conventionally zero + * (e.g., BlackPixel = 0x00000000, WhitePixel = 0x00FFFFFF). SDL2 treats + * alpha=0 as fully transparent, so passing those pixels through verbatim + * makes everything drawn with the default GC foreground invisible. + * Promote at the single entry point so every primitive routed through + * applySdlDrawState() inherits the fix. */ + color = colorWithOpaqueDefault(color); + GraphicContext *gContext = gc ? GET_GC(gc) : NULL; unsigned long generation = gContext ? gContext->generation : 0; if (lastDrawState.valid && lastDrawState.renderer == renderer && @@ -672,6 +792,204 @@ static void repaintMappedChildrenInRect(Display *display, presentDrawableIfVisible(drawable); } +static int compareInts(const void *a, const void *b) +{ + int lhs = *(const int *) a; + int rhs = *(const int *) b; + return (lhs > rhs) - (lhs < rhs); +} + +/* Splits and removals can produce more outputs than inputs at any given + * index, so we cannot write back into `segments` in place: a write to + * segments[outCount] when outCount > i would overwrite an unread input + * segment. Output through a caller-provided scratch buffer sized for + * the worst case (every segment splits in two), then copy back. */ +static void subtractSegment(SDL_Rect *segments, + SDL_Rect *scratch, + int *segmentCount, + int removeX1, + int removeX2) +{ + int outCount = 0; + int count = *segmentCount; + for (int i = 0; i < count; i++) { + /* Extents in int64 so a segment near INT_MAX cannot wrap when + * width is added. Width differences are clamped back into int + * before writing into SDL_Rect.w (caller-side checks guarantee + * the post-clamp values are well-formed). */ + int64_t segX1 = segments[i].x; + int64_t segX2 = (int64_t) segments[i].x + (int64_t) segments[i].w; + if ((int64_t) removeX2 <= segX1 || (int64_t) removeX1 >= segX2) { + scratch[outCount++] = segments[i]; + continue; + } + if ((int64_t) removeX1 > segX1) { + int64_t w = (int64_t) removeX1 - segX1; + if (w > INT_MAX) + w = INT_MAX; + scratch[outCount++] = (SDL_Rect) { + .x = (int) segX1, + .y = segments[i].y, + .w = (int) w, + .h = segments[i].h, + }; + } + if ((int64_t) removeX2 < segX2) { + int64_t w = segX2 - (int64_t) removeX2; + if (w > INT_MAX) + w = INT_MAX; + scratch[outCount++] = (SDL_Rect) { + .x = removeX2, + .y = segments[i].y, + .w = (int) w, + .h = segments[i].h, + }; + } + } + if (outCount > 0) + memcpy(segments, scratch, sizeof(SDL_Rect) * (size_t) outCount); + *segmentCount = outCount; +} + +static Bool renderFillRectClipByChildren(SDL_Renderer *renderer, + Window window, + const SDL_Rect *rect) +{ + if (!renderer || !rect || rect->w <= 0 || rect->h <= 0) + return True; + if (!IS_TYPE(window, WINDOW) || + GET_WINDOW_STRUCT(window)->children.length == 0) { + return SDL_RenderFillRect(renderer, rect) == 0 ? True : False; + } + + WindowStruct *parent = GET_WINDOW_STRUCT(window); + size_t childCount = parent->children.length; + /* Bound the allocation so a pathological window tree can't wrap the + * malloc size and bypass the OOM guard. */ + size_t childLimit = SIZE_MAX / sizeof(SDL_Rect) / 4; + if (childCount > childLimit) { + return SDL_RenderFillRect(renderer, rect) == 0 ? True : False; + } + int *edges = malloc(sizeof(int) * (childCount * 2 + 2)); + SDL_Rect *segments = malloc(sizeof(SDL_Rect) * (childCount + 1)); + SDL_Rect *segScratch = malloc(sizeof(SDL_Rect) * (childCount + 1) * 2); + if (!edges || !segments || !segScratch) { + free(edges); + free(segments); + free(segScratch); + return SDL_RenderFillRect(renderer, rect) == 0 ? True : False; + } + + /* Compute rect extents in int64_t so a rect at the int range cannot + * wrap before clamping. */ + int64_t rectX1_64 = rect->x; + int64_t rectX2_64 = (int64_t) rect->x + (int64_t) rect->w; + int64_t rectY2_64 = (int64_t) rect->y + (int64_t) rect->h; + if (rectX2_64 > INT_MAX) + rectX2_64 = INT_MAX; + if (rectY2_64 > INT_MAX) + rectY2_64 = INT_MAX; + int rectY2 = (int) rectY2_64; + + int edgeCount = 0; + edges[edgeCount++] = rect->y; + edges[edgeCount++] = rectY2; + Window *children = GET_CHILDREN(window); + for (size_t i = 0; i < childCount; i++) { + Window child = children[i]; + if (child == None || !IS_TYPE(child, WINDOW)) + continue; + WindowStruct *childStruct = GET_WINDOW_STRUCT(child); + if (childStruct->mapState != Mapped) + continue; + SDL_Rect childRect = { + .x = childStruct->x, + .y = childStruct->y, + .w = clampToInt(childStruct->w), + .h = clampToInt(childStruct->h), + }; + SDL_Rect overlap; + if (!SDL_IntersectRect(rect, &childRect, &overlap)) + continue; + int64_t overlapY2 = (int64_t) overlap.y + (int64_t) overlap.h; + if (overlapY2 > INT_MAX) + overlapY2 = INT_MAX; + edges[edgeCount++] = overlap.y; + edges[edgeCount++] = (int) overlapY2; + } + qsort(edges, (size_t) edgeCount, sizeof(int), compareInts); + + Bool ok = True; + for (int e = 0; e + 1 < edgeCount; e++) { + int bandY1 = edges[e]; + int bandY2 = edges[e + 1]; + if (bandY1 == bandY2) + continue; + int segmentCount = 1; + segments[0] = (SDL_Rect) { + .x = rect->x, + .y = bandY1, + .w = rect->w, + .h = bandY2 - bandY1, + }; + for (size_t i = 0; i < childCount && segmentCount > 0; i++) { + Window child = children[i]; + if (child == None || !IS_TYPE(child, WINDOW)) + continue; + WindowStruct *childStruct = GET_WINDOW_STRUCT(child); + if (childStruct->mapState != Mapped) + continue; + int64_t childY1_64 = childStruct->y; + int64_t childY2_64 = + (int64_t) childStruct->y + (int64_t) clampToInt(childStruct->h); + if (childY2_64 <= bandY1 || childY1_64 >= bandY2) + continue; + int64_t childX1_64 = childStruct->x; + int64_t childX2_64 = + (int64_t) childStruct->x + (int64_t) clampToInt(childStruct->w); + if (childX2_64 <= rectX1_64 || childX1_64 >= rectX2_64) + continue; + if (childX1_64 < rectX1_64) + childX1_64 = rectX1_64; + if (childX2_64 > rectX2_64) + childX2_64 = rectX2_64; + subtractSegment(segments, segScratch, &segmentCount, + (int) childX1_64, (int) childX2_64); + } + for (int i = 0; i < segmentCount; i++) { + if (segments[i].w <= 0 || segments[i].h <= 0) + continue; + if (SDL_RenderFillRect(renderer, &segments[i]) != 0) + ok = False; + } + } + + free(edges); + free(segments); + free(segScratch); + return ok; +} + +static Bool getDrawableSize(Drawable drawable, int *width, int *height) +{ + if (IS_TYPE(drawable, WINDOW)) { + unsigned int w = 0, h = 0; + GET_WINDOW_DIMS(drawable, w, h); + *width = clampToInt(w); + *height = clampToInt(h); + return True; + } + if (IS_TYPE(drawable, PIXMAP)) { + PixmapStruct *pixmap = GET_PIXMAP_STRUCT(drawable); + if (!pixmap) + return False; + *width = clampToInt(pixmap->width); + *height = clampToInt(pixmap->height); + return True; + } + return False; +} + /* Cap per-side stroke padding so the bbox arithmetic can't overflow signed * int when an X client passes an extreme lineWidth. SDL_Rect is signed int * wide; values larger than this practically can't draw anyway. */ @@ -1101,6 +1419,14 @@ static void rasterOpRendererRect(SDL_Renderer *renderer, if (!SDL_IntersectRect(rect, &bounds, &clipped)) return; + /* Guard the row * height multiplication so an oversize intersect + * cannot wrap size_t before malloc; also keep the pitch within + * SDL_RenderReadPixels' int pitch argument. */ + if (clipped.w <= 0 || clipped.h <= 0 || + (size_t) clipped.w > SIZE_MAX / 4u || + (size_t) clipped.h > SIZE_MAX / ((size_t) clipped.w * 4u) || + (size_t) clipped.w * 4u > (size_t) INT_MAX) + return; size_t pitch = (size_t) clipped.w * 4u; unsigned char *pixels = malloc(pitch * (size_t) clipped.h); if (!pixels) @@ -1225,6 +1551,14 @@ static void rasterOpRendererLine(SDL_Renderer *renderer, if (!SDL_IntersectRect(&bbox, &bounds, &clipped)) return; + /* Guard the row * height multiplication so an oversize intersect + * cannot wrap size_t before malloc; also keep the pitch within + * SDL_RenderReadPixels' int pitch argument. */ + if (clipped.w <= 0 || clipped.h <= 0 || + (size_t) clipped.w > SIZE_MAX / 4u || + (size_t) clipped.h > SIZE_MAX / ((size_t) clipped.w * 4u) || + (size_t) clipped.w * 4u > (size_t) INT_MAX) + return; size_t pitch = (size_t) clipped.w * 4u; unsigned char *pixels = malloc(pitch * (size_t) clipped.h); if (!pixels) @@ -2472,7 +2806,6 @@ int XClearArea(register Display *dpy, if (clearY + clearHeight > winHeight) { clearHeight = winHeight - clearY; } - SDL_Renderer *renderer = NULL; GET_RENDERER(w, renderer); if (!renderer) { @@ -2524,7 +2857,14 @@ int XClearArea(register Display *dpy, } } else { applySdlDrawState(renderer, NULL, SDL_BLENDMODE_NONE, backgroundColor); - SDL_RenderFillRect(renderer, &clearRect); + if (!renderFillRectClipByChildren(renderer, w, &clearRect)) { + clearRendererClip(renderer); + shapeGuardEnd(&sg); + LOG("SDL_RenderFillRect failed in %s: %s\n", __func__, + SDL_GetError()); + handleError(0, dpy, w, 0, BadDrawable, 0); + return 0; + } } shapeGuardEnd(&sg); repaintMappedChildrenInRect(dpy, w, &clearRect); @@ -2542,6 +2882,186 @@ int XClearArea(register Display *dpy, return 1; } +/* Read-modify-write XCopyArea for non-GXcopy/masked GCs. Returns: + * 0 - GC is GXcopy with full plane mask; fall through to the fast + * plain-blit path. + * 1 - request handled successfully (read, applied function per + * pixel, wrote back). + * -1 - request failed (intermediate alloc/readback returned an + * error). XCopyArea should report failure to the caller. + */ +static int xCopyAreaRasterOp(Display *display, + Drawable src, + Drawable dest, + GC gc, + int src_x, + int src_y, + unsigned int width, + unsigned int height, + int dest_x, + int dest_y) +{ + if (!gc) + return 0; + GraphicContext *gContext = GET_GC(gc); + if (!gContext) + return 0; + int gcFunction = gContext->function; + unsigned long gcPlaneMask = gContext->planeMask; + if (gcFunction == GXcopy && (gcPlaneMask & 0x00FFFFFFul) == 0x00FFFFFFul) { + return 0; + } + if (width == 0 || height == 0) + return 1; + if (width > (unsigned int) (INT_MAX / (int) sizeof(Uint32))) { + handleError(0, display, src, 0, BadValue, 0); + return -1; + } + + /* Resolve the source renderer FIRST and read its pixels before the + * destination renderer is touched. GET_RENDERER can lazily attach + * an SDL_Texture render target to the drawable, which switches the + * active target. If we resolved dest first and then read from src, + * a same-renderer src/dest pair would read destination pixels into + * srcPixels and silently corrupt the GXxor/GXand result. */ + SDL_Renderer *srcRenderer = NULL; + GET_RENDERER(src, srcRenderer); + if (!srcRenderer) { + handleError(0, display, src, 0, BadDrawable, 0); + return -1; + } + int destWidth = 0; + int destHeight = 0; + if (!getDrawableSize(dest, &destWidth, &destHeight)) { + handleError(0, display, dest, 0, BadDrawable, 0); + return -1; + } + SDL_Rect requestedDestRect = {dest_x, dest_y, (int) width, (int) height}; + SDL_Rect destBounds = {0, 0, destWidth, destHeight}; + SDL_Rect destRect; + if (!SDL_IntersectRect(&requestedDestRect, &destBounds, &destRect)) { + if (gcWantsGraphicsExposures(gc)) + postEvent(display, dest, NoExpose, X_CopyArea, 0); + return 1; + } + int64_t srcReadX = (int64_t) src_x + ((int64_t) destRect.x - dest_x); + int64_t srcReadY = (int64_t) src_y + ((int64_t) destRect.y - dest_y); + if (srcReadX < INT_MIN || srcReadX > INT_MAX || srcReadY < INT_MIN || + srcReadY > INT_MAX) { + handleError(0, display, src, 0, BadValue, 0); + return -1; + } + SDL_Rect requestedSrcRect = {src_x, src_y, (int) width, (int) height}; + SDL_Rect srcRect = {(int) srcReadX, (int) srcReadY, destRect.w, destRect.h}; + + size_t pixelCount = (size_t) destRect.w * (size_t) destRect.h; + if (pixelCount > SIZE_MAX / sizeof(Uint32)) + return -1; + /* calloc instead of malloc: some SDL_RenderReadPixels backends + * silently partial-fill when the requested rect intersects + * out-of-bounds; the rest stays zero rather than feeding + * uninitialized heap into applyRasterFunction. */ + Uint32 *srcPixels = calloc(pixelCount, sizeof(Uint32)); + Uint32 *destPixels = calloc(pixelCount, sizeof(Uint32)); + if (!srcPixels || !destPixels) { + free(srcPixels); + free(destPixels); + handleOutOfMemory(0, display, 0, 0); + return -1; + } + + /* Free path is unified: all error exits jump to cleanup_pixels, + * which frees both pixel buffers. srcPixels is no longer touched + * after the pixel-math loop, but holding onto it until the single + * cleanup site is far simpler than threading separate free sites. */ + size_t pitchSize = (size_t) destRect.w * sizeof(Uint32); + int pitch = (int) pitchSize; + SDL_Texture *texture = NULL; + SDL_Renderer *destRenderer = NULL; + /* SDL_RenderReadPixels reads absolute render-target coordinates and + * ignores the active viewport, while the caller passes srcRect / + * destRect in drawable-local (viewport-relative) coordinates. For a + * child window whose renderer is offset, raw read would pull from + * the wrong pixels. Translate by each renderer's viewport origin + * the same way getRenderSurfaceRect does for its readback. */ + SDL_Rect srcViewport; + SDL_RenderGetViewport(srcRenderer, &srcViewport); + SDL_Rect srcReadRect = {srcRect.x + srcViewport.x, + srcRect.y + srcViewport.y, srcRect.w, srcRect.h}; + if (SDL_RenderReadPixels(srcRenderer, &srcReadRect, + SDL_PIXELFORMAT_ARGB8888, srcPixels, pitch) != 0) { + LOG("SDL_RenderReadPixels src failed in %s: %s\n", __func__, + SDL_GetError()); + goto cleanup_pixels; + } + GET_RENDERER(dest, destRenderer); + if (!destRenderer) { + handleError(0, display, dest, 0, BadDrawable, 0); + goto cleanup_pixels; + } + SDL_Rect destViewport; + SDL_RenderGetViewport(destRenderer, &destViewport); + SDL_Rect destReadRect = {destRect.x + destViewport.x, + destRect.y + destViewport.y, destRect.w, + destRect.h}; + if (SDL_RenderReadPixels(destRenderer, &destReadRect, + SDL_PIXELFORMAT_ARGB8888, destPixels, + pitch) != 0) { + LOG("SDL_RenderReadPixels dest failed in %s: %s\n", __func__, + SDL_GetError()); + goto cleanup_pixels; + } + + for (size_t i = 0; i < pixelCount; i++) { + destPixels[i] = applyRasterFunction(gcFunction, (Uint32) gcPlaneMask, + srcPixels[i], destPixels[i]); + } + + SDL_Surface *surface = + SDL_CreateRGBSurfaceWithFormatFrom(destPixels, destRect.w, destRect.h, + 32, pitch, SDL_PIXELFORMAT_ARGB8888); + if (!surface) + goto cleanup_pixels; + texture = SDL_CreateTextureFromSurface(destRenderer, surface); + SDL_FreeSurface(surface); + if (!texture) + goto cleanup_pixels; + SDL_SetTextureBlendMode(texture, SDL_BLENDMODE_NONE); + + ShapeGuard sg; + shapeGuardBegin(&sg, dest, destRenderer, &destRect); + int clipCount = getGcClipIterationCount(gc); + int rc = 0; + for (int clip = 0; clip < clipCount; clip++) { + if (!setGcClipForIteration(destRenderer, gc, clip)) + continue; + if (SDL_RenderCopy(destRenderer, texture, NULL, &destRect) != 0) { + LOG("SDL_RenderCopy failed in %s raster path: %s\n", __func__, + SDL_GetError()); + rc = -1; + break; + } + } + clearRendererClip(destRenderer); + Bool shapeOk = shapeGuardEnd(&sg); + SDL_DestroyTexture(texture); + free(srcPixels); + free(destPixels); + if (rc != 0) { + handleError(0, display, src, 0, BadMatch, 0); + return -1; + } + postCopyAreaExposure(display, src, dest, gc, &requestedSrcRect, &destRect); + if (shapeOk) + presentDrawableIfVisible(dest); + return 1; + +cleanup_pixels: + free(srcPixels); + free(destPixels); + return -1; +} + int XCopyArea(Display *display, Drawable src, Drawable dest, @@ -2558,6 +3078,29 @@ int XCopyArea(Display *display, TYPE_CHECK(src, DRAWABLE, display, 0); TYPE_CHECK(dest, DRAWABLE, display, 0); LOG("%s: Copy area from 0x%08lx to 0x%08lx\n", __func__, src, dest); + /* Reject anything whose extents would overflow signed int. Downstream + * SDL_Rect math uses int x/y/w/h and would wrap; capping width/height + * alone is not enough because src_x + width or dest_x + width can + * still overflow with a negative-leaning coordinate. */ + if (width > (unsigned int) INT_MAX || height > (unsigned int) INT_MAX || + (int64_t) src_x + (int64_t) width > INT_MAX || + (int64_t) src_y + (int64_t) height > INT_MAX || + (int64_t) dest_x + (int64_t) width > INT_MAX || + (int64_t) dest_y + (int64_t) height > INT_MAX) { + handleError(0, display, src, 0, BadValue, 0); + return 0; + } + /* Non-GXcopy or masked plane masks need per-pixel arithmetic that + * SDL_RenderCopy cannot express. xCopyAreaRasterOp does the slow + * read-modify-write through SDL_RenderReadPixels and returns 1 if + * it handled the request, 0 to fall through to the fast plain-blit + * path. */ + { + int rasterRc = xCopyAreaRasterOp(display, src, dest, gc, src_x, src_y, + width, height, dest_x, dest_y); + if (rasterRc != 0) + return rasterRc > 0 ? 1 : 0; + } if (IS_TYPE(src, WINDOW)) { if (IS_INPUT_ONLY(src)) { LOG("BadMatch: Got input only window as the source in %s!\n", @@ -2626,9 +3169,8 @@ int XCopyArea(Display *display, handleError(0, display, src, 0, BadMatch, 0); return 0; } - if (GET_GC(gc)->graphicsExposures) { - postEvent(display, dest, NoExpose, X_CopyArea, 0); - } + postCopyAreaExposure(display, src, dest, gc, &fastSrc, + &fastDest); if (fastShapeOk) presentDrawableIfVisible(dest); return 1; @@ -2646,6 +3188,7 @@ int XCopyArea(Display *display, .w = (int) width, .h = (int) height, }; + SDL_Rect requestedSrcRect = srcRect; SDL_Rect destRect = { .x = dest_x, .y = dest_y, @@ -2701,19 +3244,82 @@ int XCopyArea(Display *display, return 0; } SDL_DestroyTexture(srcTexture); - if (GET_GC(gc)->graphicsExposures) { - postEvent(display, dest, NoExpose, X_CopyArea, 0); - } + postCopyAreaExposure(display, src, dest, gc, &requestedSrcRect, + &destRect); if (slowShapeOk) presentDrawableIfVisible(dest); return 1; - } else { - LOG("Hit unimplemented type in %s: %d\n", __func__, GET_XID_TYPE(dest)); } - if (GET_GC(gc)->graphicsExposures) { - postEvent(display, dest, NoExpose, X_CopyArea, 0); + SDL_Renderer *srcRenderer; + GET_RENDERER(src, srcRenderer); + if (!srcRenderer) { + handleError(0, display, src, 0, BadDrawable, 0); + return 0; + } + SDL_Rect srcRect = { + .x = src_x, + .y = src_y, + .w = (int) width, + .h = (int) height, + }; + SDL_Surface *srcSurface = getRenderSurfaceRect(srcRenderer, &srcRect); + if (!srcSurface) { + handleError(0, display, src, 0, BadMatch, 0); + return 0; + } + + SDL_Renderer *destRenderer; + GET_RENDERER(dest, destRenderer); + if (!destRenderer) { + SDL_FreeSurface(srcSurface); + handleError(0, display, dest, 0, BadDrawable, 0); + return 0; + } + SDL_Texture *srcTexture = + SDL_CreateTextureFromSurface(destRenderer, srcSurface); + SDL_FreeSurface(srcSurface); + if (!srcTexture) { + LOG("SDL_CreateTextureFromSurface failed in %s pixmap path: %s\n", + __func__, SDL_GetError()); + handleError(0, display, src, 0, BadMatch, 0); + return 0; + } + + SDL_Rect copySrc = { + .x = 0, + .y = 0, + .w = (int) width, + .h = (int) height, + }; + SDL_Rect destRect = { + .x = dest_x, + .y = dest_y, + .w = (int) width, + .h = (int) height, + }; + SDL_SetTextureBlendMode(srcTexture, SDL_BLENDMODE_NONE); + int clipCount = getGcClipIterationCount(gc); + int rcCopy = 0; + for (int clip = 0; clip < clipCount; clip++) { + if (!setGcClipForIteration(destRenderer, gc, clip)) + continue; + if (SDL_RenderCopy(destRenderer, srcTexture, ©Src, &destRect) != + 0) { + LOG("SDL_RenderCopy failed in %s pixmap path: %s\n", __func__, + SDL_GetError()); + rcCopy = -1; + break; + } } + clearRendererClip(destRenderer); + SDL_DestroyTexture(srcTexture); + if (rcCopy != 0) { + handleError(0, display, src, 0, BadMatch, 0); + return 0; + } + + postCopyAreaExposure(display, src, dest, gc, &srcRect, &destRect); presentDrawableIfVisible(dest); return 1; } diff --git a/src/events.c b/src/events.c index 57b858b..6cc5945 100644 --- a/src/events.c +++ b/src/events.c @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -14,14 +15,108 @@ #include "util.h" #include "drawing.h" #include "window-internal.h" +#include "replay-target.h" +#include "snapshot.h" #include "X11/Xlibint.h" -int eventFds[2]; +int eventFds[2] = {-1, -1}; #define READ_EVENT_FD eventFds[0] #define WRITE_EVENT_FD eventFds[1] unsigned long lastEventSerial = 1; static SDL_mutex *eventQueueLengthLock = NULL; +/* Concurrency: SDL invokes onSdlEvent on its own event thread while + * Xlib API entries run on the application thread. Each shared piece of + * state has its own mutex; helper wrappers tolerate a NULL lock so + * static initializers and pre-init code do not deadlock. The locks + * are created in initEventPipe and destroyed when the last display + * closes. */ +static SDL_mutex *putBackEventsLock = NULL; +static SDL_mutex *trackedDisplaysLock = NULL; +static SDL_mutex *activePointerWindowLock = NULL; +static Uint32 xtWakeEventType = (Uint32) -1; +static SDL_TimerID xtWakeTimer = 0; +static Array trackedDisplays = {NULL, 0, 0}; + +/* Serializes the first-open / last-close blocks in initEventPipe and + * closeEventPipe. The named SDL_mutex slots they manage are created and + * destroyed there, so a parallel open + close on the same boundary + * (length 0 -> 1 racing length 1 -> 0) would otherwise race on + * SDL_CreateMutex / SDL_DestroyMutex and on the wake timer setup. */ +static SDL_SpinLock eventPipeGlobalLock; +static void lockPutBackEvents(void) +{ + if (putBackEventsLock) + SDL_LockMutex(putBackEventsLock); +} +static void unlockPutBackEvents(void) +{ + if (putBackEventsLock) + SDL_UnlockMutex(putBackEventsLock); +} +static void lockTrackedDisplays(void) +{ + if (trackedDisplaysLock) + SDL_LockMutex(trackedDisplaysLock); +} +static void unlockTrackedDisplays(void) +{ + if (trackedDisplaysLock) + SDL_UnlockMutex(trackedDisplaysLock); +} +static void lockActivePointerWindow(void) +{ + if (activePointerWindowLock) + SDL_LockMutex(activePointerWindowLock); +} +static void unlockActivePointerWindow(void) +{ + if (activePointerWindowLock) + SDL_UnlockMutex(activePointerWindowLock); +} + +static void clearWindowTreeWithoutExpose(Display *display, Window window) +{ + if (window == None || !IS_TYPE(window, WINDOW) || IS_INPUT_ONLY(window) || + GET_WINDOW_STRUCT(window)->mapState == UnMapped) { + return; + } + XClearArea(display, window, 0, 0, 0, 0, False); + Window *children = GET_CHILDREN(window); + size_t childCount = GET_WINDOW_STRUCT(window)->children.length; + for (size_t i = 0; i < childCount; i++) { + clearWindowTreeWithoutExpose(display, children[i]); + } +} + +static void postFullWindowExpose(Display *display, Window window) +{ + if (window == None || !IS_TYPE(window, WINDOW) || IS_INPUT_ONLY(window) || + GET_WINDOW_STRUCT(window)->mapState != Mapped) { + return; + } + SDL_Rect exposeRect = {0, 0, 0, 0}; + GET_WINDOW_DIMS(window, exposeRect.w, exposeRect.h); + postExposeEvent(display, window, &exposeRect, 1); +} + +void postSyntheticWindowResize(Display *display, + Window eventWindow, + int width, + int height) +{ + if (eventWindow == None || !IS_TYPE(eventWindow, WINDOW) || width <= 0 || + height <= 0) { + return; + } + GET_WINDOW_STRUCT(eventWindow)->w = (unsigned int) width; + GET_WINDOW_STRUCT(eventWindow)->h = (unsigned int) height; + resizeWindowTexture(eventWindow); + clearWindowTreeWithoutExpose(display, eventWindow); + postEvent(display, eventWindow, ConfigureNotify); + postFullWindowExpose(display, eventWindow); +} + /* SDL_PumpEvents is only safe on the thread that owns SDL windows. XOpenDisplay * captures that owner before client threads can issue Xlib requests. */ static SDL_threadID mainEventThreadId = 0; @@ -57,6 +152,32 @@ static void pumpEventsSafe(void) SDL_PumpEvents(); } +static Uint32 xtWakeTimerCallback(Uint32 interval, void *param) +{ + (void) param; + if (xtWakeEventType == (Uint32) -1) + return interval; + /* Rate-limit: if the consumer hasn't drained the previous wake event, + * don't queue another. Otherwise an idle (or slow-dispatching) client + * accumulates wake events in SDL's queue and the corresponding pipe + * bytes via onSdlEvent's accounting, both of which would surface as + * phantom "events pending" to XEventsQueued and to external + * select(ConnectionNumber) consumers. */ + if (SDL_HasEvent(xtWakeEventType)) + return interval; + SDL_Event event; + SDL_zero(event); + event.type = xtWakeEventType; + SDL_PushEvent(&event); + return interval; +} + +static Bool shouldStartXtWakeTimer(void) +{ + const char *driver = SDL_GetCurrentVideoDriver(); + return !driver || strcmp(driver, "dummy") != 0; +} + typedef struct PutBackEvent { Display *display; XEvent event; @@ -66,6 +187,9 @@ typedef struct PutBackEvent { static PutBackEvent *putBackEvents = NULL; static void updateWindowRenderTargets(Display *display); +static int onSdlEvent(void *userdata, SDL_Event *event); +static Bool getEventQueueLength(int *qlen); +static int countPutBackEvents(Display *display); int convertEvent(Display *display, SDL_Event *sdlEvent, XEvent *xEvent, @@ -154,6 +278,50 @@ static void resetEventWakeups(Display *display, int qlen) decrementDisplayEventQueueLength(display); \ } while (0) +void wakeEventPipeForExternalEvent(Display *display) +{ + (void) display; + if (WRITE_EVENT_FD < 0) + return; + int sdlQueued = 0; + Bool haveSdlQueued = getEventQueueLength(&sdlQueued); + int maxDesiredQlen = 0; + /* SDL has one process-wide event queue, but Xt/Motif may have multiple + * Display handles alive and the one running XtAppNextEvent is not + * necessarily the Display passed to XTest. Wake every tracked display so + * whichever client loop polls first sees the synthetic input promptly. */ + lockTrackedDisplays(); + for (size_t i = 0; i < trackedDisplays.length; i++) { + Display *target = trackedDisplays.array[i]; + if (!target) + continue; + if (haveSdlQueued) { + int desiredQlen = countPutBackEvents(target) + sdlQueued; + if (desiredQlen > maxDesiredQlen) + maxDesiredQlen = desiredQlen; + setDisplayEventQueueLength(target, desiredQlen); + } else { + ENQUEUE_EVENT_IN_PIPE(target); + } + } + unlockTrackedDisplays(); + if (haveSdlQueued) { + char buffer[64]; + memset(buffer, 'e', sizeof(buffer)); + discardPipeWakeups(); + int remaining = maxDesiredQlen; + while (remaining > 0) { + size_t chunk = (size_t) remaining; + if (chunk > sizeof(buffer)) + chunk = sizeof(buffer); + ssize_t written = write(WRITE_EVENT_FD, buffer, chunk); + if (written <= 0) + break; + remaining -= (int) written; + } + } +} + static Bool getRectIntersection(const SDL_Rect *rect1, const SDL_Rect *rect2, SDL_Rect *rectOut) @@ -260,8 +428,11 @@ static int onSdlEvent(void *userdata, SDL_Event *event) return 0; } /* Fall through to enqueue non-screen window events. */ + ENQUEUE_EVENT_IN_PIPE((Display *) userdata); + break; default: ENQUEUE_EVENT_IN_PIPE((Display *) userdata); + break; } return 1; } @@ -276,7 +447,7 @@ static Bool getEventQueueLength(int *qlen) * * The cap is just a peek buffer; size it large enough for realistic * bursts but bounded so a runaway SDL queue can't blow the stack. */ - enum { PEEK_CAP = 256 }; + enum { PEEK_CAP = 4096 }; SDL_Event tmp[PEEK_CAP]; *qlen = SDL_PeepEvents(tmp, PEEK_CAP, SDL_PEEKEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT); @@ -297,8 +468,10 @@ static Bool enqueuePutBackEvent(Display *display, const XEvent *event) } node->display = display; memcpy(&node->event, event, sizeof(XEvent)); + lockPutBackEvents(); node->next = putBackEvents; putBackEvents = node; + unlockPutBackEvents(); /* Match the SDL filter's pipe-byte accounting so that clients * select()ing on ConnectionNumber actually wake up for put-backs. */ ENQUEUE_EVENT_IN_PIPE(display); @@ -316,22 +489,26 @@ static Bool appendPutBackEvent(Display *display, const XEvent *event) memcpy(&node->event, event, sizeof(XEvent)); node->next = NULL; + lockPutBackEvents(); PutBackEvent **link = &putBackEvents; while (*link) link = &(*link)->next; *link = node; + unlockPutBackEvents(); ENQUEUE_EVENT_IN_PIPE(display); return True; } static Bool popPutBackEvent(Display *display, XEvent *event) { + lockPutBackEvents(); PutBackEvent **link = &putBackEvents; while (*link) { PutBackEvent *node = *link; if (node->display == display) { *link = node->next; memcpy(event, &node->event, sizeof(XEvent)); + unlockPutBackEvents(); free(node); /* Match the pipe byte enqueuePutBackEvent wrote so the wake-up * accounting stays consistent. */ @@ -340,16 +517,19 @@ static Bool popPutBackEvent(Display *display, XEvent *event) } link = &node->next; } + unlockPutBackEvents(); return False; } static int countPutBackEvents(Display *display) { int count = 0; + lockPutBackEvents(); for (PutBackEvent *node = putBackEvents; node; node = node->next) { if (node->display == display) count++; } + unlockPutBackEvents(); return count; } @@ -456,6 +636,31 @@ static void fillCrossingEvent(Display *display, event->state = state; } +static void fillCrossingEventAt(Display *display, + XCrossingEvent *event, + Window window, + int type, + int mode, + int detail, + unsigned int state, + int rootX, + int rootY, + Time time) +{ + fillCrossingEvent(display, event, window, type, mode, detail, state); + event->time = time; + event->x_root = rootX; + event->y_root = rootY; + event->x = rootX; + event->y = rootY; + if (window != SCREEN_WINDOW && IS_TYPE(window, WINDOW)) { + Window child = None; + XTranslateCoordinates(display, SCREEN_WINDOW, window, rootX, rootY, + &event->x, &event->y, &child); + event->subwindow = child; + } +} + static void translateRootPointToWindow(Display *display, Window root, Window window, @@ -504,6 +709,8 @@ static Window directChildContainingPoint(Window window, int x, int y) Window *children = GET_CHILDREN(window); for (size_t i = GET_WINDOW_STRUCT(window)->children.length; i > 0; i--) { Window child = children[i - 1]; + if (!isWindowEffectivelyViewable(child)) + continue; int childX, childY, childW, childH; GET_WINDOW_POS(child, childX, childY); GET_WINDOW_DIMS(child, childW, childH); @@ -541,6 +748,43 @@ static Window selectPointerEventWindow(Display *display, return eventWindow; } +static Bool routePointerGrabEvent(Display *display, + Window root, + int rootX, + int rootY, + long mask, + Window *eventWindow, + Window *subwindowReturn, + int *eventXReturn, + int *eventYReturn) +{ + Window grabWindow = getGrabbedPointerWindow(); + if (grabWindow == None || !IS_TYPE(grabWindow, WINDOW)) + return False; + + if (getPointerGrabOwnerEvents()) { + Window ownerWindow = selectPointerEventWindow( + display, root, rootX, rootY, mask, subwindowReturn, eventXReturn, + eventYReturn); + if (ownerWindow != None) { + *eventWindow = ownerWindow; + return True; + } + } + + if ((getPointerGrabEventMask() & mask) == 0) + return True; + + *eventWindow = grabWindow; + translateRootPointToWindow(display, root, grabWindow, rootX, rootY, + eventXReturn, eventYReturn); + if (subwindowReturn) { + *subwindowReturn = directChildContainingPoint(grabWindow, *eventXReturn, + *eventYReturn); + } + return True; +} + Bool postCrossingEvent(Display *display, Window window, int type, @@ -564,6 +808,154 @@ Bool postCrossingEvent(Display *display, return True; } +Bool postFocusEvent(Display *display, Window window, int type, int detail) +{ + if (window == None || !IS_TYPE(window, WINDOW) || + !HAS_EVENT_MASK(window, FocusChangeMask)) + return True; + + XFocusChangeEvent *event = malloc(sizeof(XFocusChangeEvent)); + if (!event) { + handleOutOfMemory(0, display, 0, 0); + return False; + } + event->type = type; + event->serial = lastEventSerial; + event->send_event = False; + event->display = display; + event->window = window; + event->mode = NotifyNormal; + event->detail = detail; + if (!enqueueEvent(display, window, event)) { + free(event); + return False; + } + return True; +} + +/* Defined later beside postPointerCrossingEvents, which reuses these. */ +static int buildWindowPathToRoot(Window window, Window *path, int capacity); +static int findPathIndex(Window *path, int count, Window window); + +static int rootDetailFor(FocusKind kind) +{ + return kind == FocusKindPointerRoot ? NotifyPointerRoot : NotifyDetailNone; +} + +/* Full ancestor walk per Xlib spec 10.5/10.7. Motif's shell modal + * bookkeeping needs the NotifyVirtual/NotifyNonlinearVirtual intermediates + * between each leaf and the LCA, not just the leaf events. Transitions + * involving None or PointerRoot also fire on the root with detail + * NotifyDetailNone or NotifyPointerRoot so listeners can tell the two + * non-window targets apart. */ +void postFocusChange(Display *display, + FocusKind oldKind, + Window oldFocus, + FocusKind newKind, + Window newFocus) +{ + if (oldKind == newKind && oldFocus == newFocus) + return; + + if (oldKind == FocusKindWindow && newKind == FocusKindWindow) { + Window oldPath[256]; + Window newPath[256]; + int oldCount = IS_TYPE(oldFocus, WINDOW) + ? buildWindowPathToRoot(oldFocus, oldPath, 256) + : 0; + int newCount = IS_TYPE(newFocus, WINDOW) + ? buildWindowPathToRoot(newFocus, newPath, 256) + : 0; + + int oldLca = -1, newLca = -1; + for (int i = 0; i < oldCount; i++) { + int j = findPathIndex(newPath, newCount, oldPath[i]); + if (j >= 0) { + oldLca = i; + newLca = j; + break; + } + } + + /* oldLca >= 0 implies both paths are non-empty: the LCA search + * only runs when oldCount > 0 and j >= 0 requires newCount > 0. */ + if (oldLca == 0) { + postFocusEvent(display, oldPath[0], FocusOut, NotifyInferior); + for (int i = newLca - 1; i > 0; i--) + postFocusEvent(display, newPath[i], FocusIn, NotifyVirtual); + postFocusEvent(display, newPath[0], FocusIn, NotifyAncestor); + return; + } + if (newLca == 0) { + postFocusEvent(display, oldPath[0], FocusOut, NotifyAncestor); + for (int i = 1; i < oldLca; i++) + postFocusEvent(display, oldPath[i], FocusOut, NotifyVirtual); + postFocusEvent(display, newPath[0], FocusIn, NotifyInferior); + return; + } + + /* Nonlinear: different subtrees, or one side has no LCA. */ + if (oldCount > 0) { + int limit = oldLca >= 0 ? oldLca : oldCount; + postFocusEvent(display, oldPath[0], FocusOut, NotifyNonlinear); + for (int i = 1; i < limit; i++) + postFocusEvent(display, oldPath[i], FocusOut, + NotifyNonlinearVirtual); + } + if (newCount > 0) { + int limit = newLca >= 0 ? newLca : newCount; + for (int i = limit - 1; i > 0; i--) + postFocusEvent(display, newPath[i], FocusIn, + NotifyNonlinearVirtual); + postFocusEvent(display, newPath[0], FocusIn, NotifyNonlinear); + } + return; + } + + /* One side is None or PointerRoot (Xlib 10.7.1: nonlinear). The + * window chain uses NotifyNonlinear[Virtual] all the way through + * the root, then the root receives a separate companion event with + * the non-window detail (rootDetailFor) naming the arrived state. */ + if (oldKind == FocusKindWindow) { + Window oldPath[256]; + int oldCount = IS_TYPE(oldFocus, WINDOW) + ? buildWindowPathToRoot(oldFocus, oldPath, 256) + : 0; + if (oldCount == 0) { + postFocusEvent(display, SCREEN_WINDOW, FocusIn, + rootDetailFor(newKind)); + return; + } + postFocusEvent(display, oldPath[0], FocusOut, NotifyNonlinear); + for (int i = 1; i < oldCount; i++) + postFocusEvent(display, oldPath[i], FocusOut, + NotifyNonlinearVirtual); + postFocusEvent(display, oldPath[oldCount - 1], FocusIn, + rootDetailFor(newKind)); + } else if (newKind == FocusKindWindow) { + Window newPath[256]; + int newCount = IS_TYPE(newFocus, WINDOW) + ? buildWindowPathToRoot(newFocus, newPath, 256) + : 0; + if (newCount == 0) { + postFocusEvent(display, SCREEN_WINDOW, FocusOut, + rootDetailFor(oldKind)); + return; + } + postFocusEvent(display, newPath[newCount - 1], FocusOut, + rootDetailFor(oldKind)); + for (int i = newCount - 1; i > 0; i--) + postFocusEvent(display, newPath[i], FocusIn, + NotifyNonlinearVirtual); + postFocusEvent(display, newPath[0], FocusIn, NotifyNonlinear); + } else { + /* None <-> PointerRoot: only the root sees the transition. */ + postFocusEvent(display, SCREEN_WINDOW, FocusOut, + rootDetailFor(oldKind)); + postFocusEvent(display, SCREEN_WINDOW, FocusIn, rootDetailFor(newKind)); + } +} + static Bool enqueueResetExposures(Display *display) { WindowStruct *screen = GET_WINDOW_STRUCT(SCREEN_WINDOW); @@ -586,6 +978,7 @@ static Bool removeMatchingPutBackEvent( XEvent *event, Bool(predicate)(Window, int, long, XEvent *)) { + lockPutBackEvents(); PutBackEvent **link = &putBackEvents; while (*link) { PutBackEvent *node = *link; @@ -593,12 +986,14 @@ static Bool removeMatchingPutBackEvent( predicate(w, type, mask, &node->event)) { *link = node->next; memcpy(event, &node->event, sizeof(XEvent)); + unlockPutBackEvents(); free(node); READ_EVENT_IN_PIPE(display); return True; } link = &node->next; } + unlockPutBackEvents(); return False; } @@ -609,34 +1004,43 @@ static Bool removeMatchingPutBackIfEvent(Display *display, char *), char *arg) { + lockPutBackEvents(); PutBackEvent **link = &putBackEvents; while (*link) { PutBackEvent *node = *link; if (node->display == display && predicate(display, &node->event, arg)) { *link = node->next; memcpy(event, &node->event, sizeof(XEvent)); + unlockPutBackEvents(); free(node); READ_EVENT_IN_PIPE(display); return True; } link = &node->next; } + unlockPutBackEvents(); return False; } void discardQueuedEventsForWindow(Display *display, Window window) { + int removedPutBackEvents = 0; + lockPutBackEvents(); PutBackEvent **link = &putBackEvents; while (*link) { PutBackEvent *node = *link; if (node->display == display && node->event.xany.window == window) { *link = node->next; free(node); - READ_EVENT_IN_PIPE(display); + removedPutBackEvents++; continue; } link = &node->next; } + unlockPutBackEvents(); + while (removedPutBackEvents-- > 0) { + READ_EVENT_IN_PIPE(display); + } int qlen = 0; getEventQueueLength(&qlen); @@ -685,21 +1089,21 @@ void discardQueuedEventsForWindow(Display *display, Window window) * dynamic Array helper so multi-display flows are not silently capped * at an arbitrary number of opens. */ -static Array trackedDisplays = {NULL, 0, 0}; - static void trackDisplay(Display *display) { - if (findInArray(&trackedDisplays, display) >= 0) { - return; - } - if (!insertArray(&trackedDisplays, display)) { - LOG("Failed to track display %p for SDL event-filter handoff\n", - (void *) display); + lockTrackedDisplays(); + if (findInArray(&trackedDisplays, display) < 0) { + if (!insertArray(&trackedDisplays, display)) { + LOG("Failed to track display %p for SDL event-filter handoff\n", + (void *) display); + } } + unlockTrackedDisplays(); } static void untrackDisplay(Display *display) { + lockTrackedDisplays(); ssize_t index = findInArray(&trackedDisplays, display); if (index >= 0) { removeArray(&trackedDisplays, (size_t) index, True); @@ -707,46 +1111,68 @@ static void untrackDisplay(Display *display) if (trackedDisplays.length == 0) { freeArray(&trackedDisplays); } + unlockTrackedDisplays(); +} + +/* Lazily create a named mutex slot, logging the SDL error on failure. + * The name is interpolated into the LOG message so each lock keeps its + * own diagnostic identity without four near-identical init blocks. */ +static Bool ensureNamedMutex(SDL_mutex **slot, const char *name) +{ + if (*slot) + return True; + *slot = SDL_CreateMutex(); + if (!*slot) { + LOG("Could not create the %s lock: %s", name, SDL_GetError()); + return False; + } + return True; +} + +static void destroyMutexSlot(SDL_mutex **slot) +{ + if (*slot) { + SDL_DestroyMutex(*slot); + *slot = NULL; + } } int initEventPipe(Display *display) { captureMainEventThreadIfUnset(); lastEventSerial = 1; - if (!eventQueueLengthLock) { - eventQueueLengthLock = SDL_CreateMutex(); - if (!eventQueueLengthLock) { - LOG("Could not create the event queue lock: %s", SDL_GetError()); - return -1; - } - } - int flags; - if (pipe(eventFds) == -1) { - LOG("Could not create the event pipe: %s", strerror(errno)); - return -1; - } - /* Real X11 clients select() / poll() on the connection FD then call - * XNextEvent in a non-blocking mode. The pipe is just a wake-up - * signal; reads happen inside XNextEvent itself, so make the FD - * non-blocking so a desynced qlen does not stall the whole event - * loop on a missing byte. The original spelling used F_SETFD - * (FD_CLOEXEC) instead of F_SETFL (O_NONBLOCK), silently leaving the - * FD in blocking mode. */ - flags = fcntl(READ_EVENT_FD, F_GETFL); - fcntl(READ_EVENT_FD, F_SETFL, flags | O_NONBLOCK); - flags = fcntl(WRITE_EVENT_FD, F_GETFL); - fcntl(WRITE_EVENT_FD, F_SETFL, flags | O_NONBLOCK); - FILE *event_write = fdopen(WRITE_EVENT_FD, "w"); - if (!event_write) { - LOG("Could not create the write input of the event pipe: %s", - strerror(errno)); + SDL_AtomicLock(&eventPipeGlobalLock); + if (!ensureNamedMutex(&eventQueueLengthLock, "event queue") || + !ensureNamedMutex(&putBackEventsLock, "put-back events") || + !ensureNamedMutex(&trackedDisplaysLock, "tracked-displays") || + !ensureNamedMutex(&activePointerWindowLock, "active-pointer-window")) { + SDL_AtomicUnlock(&eventPipeGlobalLock); return -1; } - FILE *event_read = fdopen(READ_EVENT_FD, "r"); - if (!event_read) { - LOG("Could not create the read output of the event pipe: %s", - strerror(errno)); - return -1; + /* The eventFds globals are shared across displays. Only the first + * open creates the pipe; subsequent opens reuse it. Recreating it + * per call would leak the previous fds since closeEventPipe runs + * only on the final display close. */ + lockTrackedDisplays(); + Bool isFirstDisplay = trackedDisplays.length == 0; + unlockTrackedDisplays(); + if (isFirstDisplay) { + if (pipe(eventFds) == -1) { + LOG("Could not create the event pipe: %s", strerror(errno)); + SDL_AtomicUnlock(&eventPipeGlobalLock); + return -1; + } + /* Real X11 clients select() / poll() on the connection FD then + * call XNextEvent in a non-blocking mode. The pipe is just a + * wake-up signal; reads happen inside XNextEvent itself, so + * make the FD non-blocking so a desynced qlen does not stall + * the whole event loop on a missing byte. The original spelling + * used F_SETFD (FD_CLOEXEC) instead of F_SETFL (O_NONBLOCK), + * silently leaving the FD in blocking mode. */ + int flags = fcntl(READ_EVENT_FD, F_GETFL); + fcntl(READ_EVENT_FD, F_SETFL, flags | O_NONBLOCK); + flags = fcntl(WRITE_EVENT_FD, F_GETFL); + fcntl(WRITE_EVENT_FD, F_SETFL, flags | O_NONBLOCK); } int qlen; getEventQueueLength(&qlen); @@ -756,6 +1182,13 @@ int initEventPipe(Display *display) } SDL_SetEventFilter(onSdlEvent, display); trackDisplay(display); + if (isFirstDisplay && xtWakeTimer == 0 && shouldStartXtWakeTimer()) { + if (xtWakeEventType == (Uint32) -1) + xtWakeEventType = SDL_RegisterEvents(1); + if (xtWakeEventType != (Uint32) -1) + xtWakeTimer = SDL_AddTimer(33, xtWakeTimerCallback, NULL); + } + SDL_AtomicUnlock(&eventPipeGlobalLock); return READ_EVENT_FD; } @@ -772,27 +1205,50 @@ int initEventPipe(Display *display) */ void closeEventPipe(Display *display) { + SDL_AtomicLock(&eventPipeGlobalLock); untrackDisplay(display); SDL_EventFilter currentFilter = NULL; void *currentUserdata = NULL; SDL_GetEventFilter(¤tFilter, ¤tUserdata); - if (currentFilter != onSdlEvent || currentUserdata != display) { - if (trackedDisplays.length == 0 && eventQueueLengthLock) { - SDL_DestroyMutex(eventQueueLengthLock); - eventQueueLengthLock = NULL; + lockTrackedDisplays(); + size_t remainingDisplays = trackedDisplays.length; + Display *handoffTarget = remainingDisplays > 0 + ? trackedDisplays.array[remainingDisplays - 1] + : NULL; + unlockTrackedDisplays(); + /* The SDL filter handoff is conditional: only the display that + * currently owns the slot (currentUserdata == display) hands it + * over. Last-display cleanup is unconditional: when no displays + * remain, mutex slots and pipe fds always tear down. Otherwise a + * close after a third-party SDL_SetEventFilter would leak the + * pipe fds and the three lock slots. */ + if (currentFilter == onSdlEvent && currentUserdata == display) { + if (remainingDisplays > 0) + SDL_SetEventFilter(onSdlEvent, handoffTarget); + else + SDL_SetEventFilter(NULL, NULL); + } + if (remainingDisplays == 0) { + if (xtWakeTimer != 0) { + SDL_RemoveTimer(xtWakeTimer); + xtWakeTimer = 0; } - return; - } - if (trackedDisplays.length > 0) { - SDL_SetEventFilter(onSdlEvent, - trackedDisplays.array[trackedDisplays.length - 1]); - } else { - SDL_SetEventFilter(NULL, NULL); - if (eventQueueLengthLock) { - SDL_DestroyMutex(eventQueueLengthLock); - eventQueueLengthLock = NULL; + destroyMutexSlot(&eventQueueLengthLock); + destroyMutexSlot(&putBackEventsLock); + destroyMutexSlot(&trackedDisplaysLock); + destroyMutexSlot(&activePointerWindowLock); + /* Release the shared pipe fds so a later XOpenDisplay creates + * fresh ones instead of leaking them. */ + if (READ_EVENT_FD >= 0) { + close(READ_EVENT_FD); + READ_EVENT_FD = -1; + } + if (WRITE_EVENT_FD >= 0) { + close(WRITE_EVENT_FD); + WRITE_EVENT_FD = -1; } } + SDL_AtomicUnlock(&eventPipeGlobalLock); } unsigned int convertModifierState(Uint16 mod) @@ -821,7 +1277,221 @@ unsigned int convertModifierState(Uint16 mod) } static unsigned int pointerButtonState = 0; + +/* pointerButtonState shares activePointerWindowLock because the two + * form one logical "pointer state" tuple. Motion and wheel handlers + * only need the current value, so snapshot it through the lock to + * avoid a torn read against the button-press/release writer. */ +static unsigned int pointerButtonStateSnapshot(void) +{ + lockActivePointerWindow(); + unsigned int s = pointerButtonState; + unlockActivePointerWindow(); + return s; +} static Window activePointerWindow = None; +static Window pointerHoverWindow = None; +static int pointerHoverRootX = 0; +static int pointerHoverRootY = 0; + +static int buildWindowPathToRoot(Window window, Window *path, int capacity) +{ + int count = 0; + while (window != None && IS_TYPE(window, WINDOW) && count < capacity) { + path[count++] = window; + if (window == SCREEN_WINDOW) + break; + window = GET_PARENT(window); + } + return count; +} + +static int findPathIndex(Window *path, int count, Window window) +{ + for (int i = 0; i < count; i++) { + if (path[i] == window) + return i; + } + return -1; +} + +static Bool appendPointerCrossingEvent(Display *display, + Window window, + int type, + int detail, + unsigned int state, + int rootX, + int rootY, + Time time) +{ + long mask = type == EnterNotify ? EnterWindowMask : LeaveWindowMask; + if (window == None || !IS_TYPE(window, WINDOW) || + !HAS_EVENT_MASK(window, mask)) + return False; + + XEvent event; + fillCrossingEventAt(display, &event.xcrossing, window, type, NotifyNormal, + detail, state, rootX, rootY, time); + return appendPutBackEvent(display, &event); +} + +void clearActivePointerWindow(void) +{ + lockActivePointerWindow(); + activePointerWindow = None; + pointerHoverWindow = None; + unlockActivePointerWindow(); +} + +/* Clear cached pointer-target XIDs when their window is being destroyed. + * Without this, the next SDL motion event drives postPointerCrossingEvents + * which walks GET_PARENT(pointerHoverWindow) into a freed WindowStruct + * (ASan-flagged heap-use-after-free in CI). Match the pattern of + * releaseButtonGrabsForWindow: invoked from destroyWindow before the + * WindowStruct is freed. */ +void clearPointerStateForWindow(Window window) +{ + if (window == None) + return; + lockActivePointerWindow(); + if (activePointerWindow == window) + activePointerWindow = None; + if (pointerHoverWindow == window) + pointerHoverWindow = None; + unlockActivePointerWindow(); +} + +static Bool postPointerCrossingEvents(Display *display, + int rootX, + int rootY, + unsigned int state, + Time time) +{ + Window newHoverWindow = getContainingWindow(SCREEN_WINDOW, rootX, rootY); + /* Hold activePointerWindowLock through the path walks AND the + * appendPointerCrossingEvent calls, not just the initial snapshot. + * Both reviewers (PR #7 round 4) flagged a UAF window in the + * earlier shape: this function dropped the lock right after + * sampling oldHoverWindow, then walked its WindowStruct via + * buildWindowPathToRoot. A concurrent XDestroyWindow on + * oldHoverWindow could pass destroyWindow's clearPointerStateForWindow + * (the lock briefly cleared and re-released) and then free the + * WindowStruct before this thread's walk dereferenced it. + * + * Lock-order check: this function calls appendPointerCrossingEvent + * -> appendPutBackEvent which takes putBackEventsLock. destroyWindow + * takes putBackEventsLock first (via discardQueuedEventsForWindow) + * and releases before reaching clearPointerStateForWindow, so the + * two threads never try to hold both locks simultaneously and there + * is no circular wait. */ + lockActivePointerWindow(); + Window oldHoverWindow = pointerHoverWindow; + if (oldHoverWindow == newHoverWindow) { + pointerHoverRootX = rootX; + pointerHoverRootY = rootY; + unlockActivePointerWindow(); + return False; + } + pointerHoverWindow = newHoverWindow; + pointerHoverRootX = rootX; + pointerHoverRootY = rootY; + + Window oldPath[256]; + Window newPath[256]; + int oldCount = buildWindowPathToRoot(oldHoverWindow, oldPath, 256); + int newCount = buildWindowPathToRoot(newHoverWindow, newPath, 256); + int oldLca = -1; + int newLca = -1; + for (int i = 0; i < oldCount; i++) { + int j = findPathIndex(newPath, newCount, oldPath[i]); + if (j >= 0) { + oldLca = i; + newLca = j; + break; + } + } + + Bool queued = False; + if (oldLca == 0 && newLca > 0) { + queued |= appendPointerCrossingEvent(display, oldPath[0], LeaveNotify, + NotifyInferior, state, rootX, + rootY, time); + for (int i = newLca - 1; i > 0; i--) { + queued |= appendPointerCrossingEvent(display, newPath[i], + EnterNotify, NotifyVirtual, + state, rootX, rootY, time); + } + queued |= appendPointerCrossingEvent(display, newPath[0], EnterNotify, + NotifyAncestor, state, rootX, + rootY, time); + unlockActivePointerWindow(); + return queued; + } + + if (newLca == 0 && oldLca > 0) { + queued |= appendPointerCrossingEvent(display, oldPath[0], LeaveNotify, + NotifyAncestor, state, rootX, + rootY, time); + for (int i = 1; i < oldLca; i++) { + queued |= appendPointerCrossingEvent(display, oldPath[i], + LeaveNotify, NotifyVirtual, + state, rootX, rootY, time); + } + queued |= appendPointerCrossingEvent(display, newPath[0], EnterNotify, + NotifyInferior, state, rootX, + rootY, time); + unlockActivePointerWindow(); + return queued; + } + + int oldLimit = oldLca >= 0 ? oldLca : oldCount; + for (int i = 0; i < oldLimit; i++) { + int detail = i == 0 ? NotifyNonlinear : NotifyNonlinearVirtual; + queued |= appendPointerCrossingEvent(display, oldPath[i], LeaveNotify, + detail, state, rootX, rootY, time); + } + + int newLimit = newLca >= 0 ? newLca : newCount; + for (int i = newLimit - 1; i >= 0; i--) { + int detail = i == 0 ? NotifyNonlinear : NotifyNonlinearVirtual; + queued |= appendPointerCrossingEvent(display, newPath[i], EnterNotify, + detail, state, rootX, rootY, time); + } + unlockActivePointerWindow(); + return queued; +} + +static Bool queueNestedPointerLeaves(Display *display, + Window topLevel, + unsigned int state, + Time time) +{ + /* Same lifetime guard as postPointerCrossingEvents: hold the lock + * through buildWindowPathToRoot and the appendPointerCrossingEvent + * calls so a concurrent destroyWindow on a hover descendant cannot + * free the WindowStruct mid-walk. */ + lockActivePointerWindow(); + Window oldHoverWindow = pointerHoverWindow; + int rootX = pointerHoverRootX; + int rootY = pointerHoverRootY; + pointerHoverWindow = None; + + if (oldHoverWindow == None || oldHoverWindow == topLevel) { + unlockActivePointerWindow(); + return False; + } + + Window oldPath[256]; + int oldCount = buildWindowPathToRoot(oldHoverWindow, oldPath, 256); + Bool queued = False; + for (int i = 0; i < oldCount && oldPath[i] != topLevel; i++) { + int detail = i == 0 ? NotifyAncestor : NotifyVirtual; + queued |= appendPointerCrossingEvent(display, oldPath[i], LeaveNotify, + detail, state, rootX, rootY, time); + } + unlockActivePointerWindow(); + return queued; +} static unsigned int convertSdlMouseButton(Uint8 button) { @@ -854,11 +1524,30 @@ static unsigned int buttonMaskForXButton(unsigned int button) } } +static long motionMaskForButtonState(unsigned int buttonState) +{ + long mask = PointerMotionMask | PointerMotionHintMask; + if (buttonState & Button1Mask) + mask |= ButtonMotionMask | Button1MotionMask; + if (buttonState & Button2Mask) + mask |= ButtonMotionMask | Button2MotionMask; + if (buttonState & Button3Mask) + mask |= ButtonMotionMask | Button3MotionMask; + if (buttonState & Button4Mask) + mask |= ButtonMotionMask | Button4MotionMask; + if (buttonState & Button5Mask) + mask |= ButtonMotionMask | Button5MotionMask; + return mask; +} + int convertEvent(Display *display, SDL_Event *sdlEvent, XEvent *xEvent, Bool freeInternalEvents) { + if (sdlEvent->type == xtWakeEventType) + return -1; + Bool sendEvent = False; Window eventWindow = None; int type = -1; @@ -933,42 +1622,80 @@ int convertEvent(Display *display, &xEvent->xbutton.y_root); xEvent->xbutton.button = convertSdlMouseButton(sdlEvent->button.button); unsigned int buttonState = buttonMaskForXButton(xEvent->xbutton.button); + /* Snapshot the pointer state under a single critical section. + * pointerButtonState and activePointerWindow describe the same + * logical "pointer is held in window X with buttons B" tuple; + * updating them in separate unlocked phases could lose or + * duplicate the activePointerWindow transition when SDL and + * application threads race on rapid press/release sequences. */ + lockActivePointerWindow(); unsigned int previousButtonState = pointerButtonState; + Window activePointerSnapshot = activePointerWindow; + unlockActivePointerWindow(); xEvent->xbutton.state = convertModifierState(SDL_GetModState()) | previousButtonState; + long buttonMask = + type == ButtonPress ? ButtonPressMask : ButtonReleaseMask; + if (type == ButtonPress) { + activatePassiveButtonGrab( + display, xEvent->xbutton.root, xEvent->xbutton.x_root, + xEvent->xbutton.y_root, xEvent->xbutton.button, + xEvent->xbutton.state); + } + /* Explicit XGrabPointer routing owns the event when + * routePointerGrabEvent returns True; otherwise fall back to the + * normal pointer-window selection (with a sticky ButtonRelease + * delivery to the last button-press recipient). */ + if (!routePointerGrabEvent(display, xEvent->xbutton.root, + xEvent->xbutton.x_root, + xEvent->xbutton.y_root, buttonMask, + &eventWindow, &xEvent->xbutton.subwindow, + &xEvent->xbutton.x, &xEvent->xbutton.y)) { + if (type == ButtonRelease && activePointerSnapshot != None && + windowSelectsAny(activePointerSnapshot, buttonMask)) { + eventWindow = activePointerSnapshot; + translateRootPointToWindow( + display, xEvent->xbutton.root, eventWindow, + xEvent->xbutton.x_root, xEvent->xbutton.y_root, + &xEvent->xbutton.x, &xEvent->xbutton.y); + xEvent->xbutton.subwindow = directChildContainingPoint( + eventWindow, xEvent->xbutton.x, xEvent->xbutton.y); + } else { + eventWindow = selectPointerEventWindow( + display, xEvent->xbutton.root, xEvent->xbutton.x_root, + xEvent->xbutton.y_root, buttonMask, + &xEvent->xbutton.subwindow, &xEvent->xbutton.x, + &xEvent->xbutton.y); + } + } + Bool drainedActivePointer = False; if (freeInternalEvents) { + /* Single critical section publishes both updates so a + * concurrent reader sees a consistent (button-state, + * active-window) pair. */ + lockActivePointerWindow(); if (type == ButtonPress) pointerButtonState |= buttonState; else pointerButtonState &= ~buttonState; + if (type == ButtonPress && previousButtonState == 0 && + eventWindow != None) { + activePointerWindow = eventWindow; + } + if (pointerButtonState == 0) { + activePointerWindow = None; + drainedActivePointer = True; + } + unlockActivePointerWindow(); } - long buttonMask = - type == ButtonPress ? ButtonPressMask : ButtonReleaseMask; - if (type == ButtonRelease && activePointerWindow != None && - windowSelectsAny(activePointerWindow, buttonMask)) { - eventWindow = activePointerWindow; - translateRootPointToWindow(display, xEvent->xbutton.root, - eventWindow, xEvent->xbutton.x_root, - xEvent->xbutton.y_root, - &xEvent->xbutton.x, &xEvent->xbutton.y); - xEvent->xbutton.subwindow = directChildContainingPoint( - eventWindow, xEvent->xbutton.x, xEvent->xbutton.y); - } else { - eventWindow = selectPointerEventWindow( - display, xEvent->xbutton.root, xEvent->xbutton.x_root, - xEvent->xbutton.y_root, buttonMask, &xEvent->xbutton.subwindow, - &xEvent->xbutton.x, &xEvent->xbutton.y); - } - if (freeInternalEvents && type == ButtonPress && - previousButtonState == 0 && eventWindow != None) { - activePointerWindow = eventWindow; - } - if (freeInternalEvents && pointerButtonState == 0) - activePointerWindow = None; if (eventWindow == None) return -1; xEvent->xbutton.window = eventWindow; xEvent->xbutton.same_screen = True; + if (freeInternalEvents && type == ButtonRelease && + drainedActivePointer && pointerGrabIsPassive()) { + releasePassivePointerGrab(display); + } break; case SDL_MOUSEMOTION: LOG("SDL_MOUSEMOTION\n"); @@ -980,34 +1707,53 @@ int convertEvent(Display *display, translateSdlPointToRoot(display, sdlMotionWindow, sdlEvent->motion.x, sdlEvent->motion.y, &xEvent->xmotion.x_root, &xEvent->xmotion.y_root); - long motionMask = PointerMotionMask | ButtonMotionMask | - Button1MotionMask | Button2MotionMask | - Button3MotionMask | Button4MotionMask | - Button5MotionMask | PointerMotionHintMask; - if (activePointerWindow != None && - windowSelectsAny(activePointerWindow, motionMask)) { - eventWindow = activePointerWindow; - translateRootPointToWindow(display, xEvent->xmotion.root, - eventWindow, xEvent->xmotion.x_root, - xEvent->xmotion.y_root, - &xEvent->xmotion.x, &xEvent->xmotion.y); - xEvent->xmotion.subwindow = directChildContainingPoint( - eventWindow, xEvent->xmotion.x, xEvent->xmotion.y); - } else { - eventWindow = selectPointerEventWindow( - display, xEvent->xmotion.root, xEvent->xmotion.x_root, - xEvent->xmotion.y_root, motionMask, &xEvent->xmotion.subwindow, - &xEvent->xmotion.x, &xEvent->xmotion.y); + unsigned int motionButtonState = pointerButtonStateSnapshot(); + unsigned int motionState = + convertModifierState(SDL_GetModState()) | motionButtonState; + Bool crossingQueued = postPointerCrossingEvents( + display, xEvent->xmotion.x_root, xEvent->xmotion.y_root, + motionState, sdlEvent->motion.timestamp); + long motionMask = motionMaskForButtonState(motionButtonState); + /* Explicit XGrabPointer routing owns the event when + * routePointerGrabEvent returns True; otherwise the implicit + * button-press grab (snapshot != None) wins, falling back to + * regular pointer-window selection. */ + if (!routePointerGrabEvent(display, xEvent->xmotion.root, + xEvent->xmotion.x_root, + xEvent->xmotion.y_root, motionMask, + &eventWindow, &xEvent->xmotion.subwindow, + &xEvent->xmotion.x, &xEvent->xmotion.y)) { + lockActivePointerWindow(); + Window snapshot = activePointerWindow; + unlockActivePointerWindow(); + if (snapshot != None && windowSelectsAny(snapshot, motionMask)) { + eventWindow = snapshot; + translateRootPointToWindow( + display, xEvent->xmotion.root, eventWindow, + xEvent->xmotion.x_root, xEvent->xmotion.y_root, + &xEvent->xmotion.x, &xEvent->xmotion.y); + xEvent->xmotion.subwindow = directChildContainingPoint( + eventWindow, xEvent->xmotion.x, xEvent->xmotion.y); + } else { + eventWindow = selectPointerEventWindow( + display, xEvent->xmotion.root, xEvent->xmotion.x_root, + xEvent->xmotion.y_root, motionMask, + &xEvent->xmotion.subwindow, &xEvent->xmotion.x, + &xEvent->xmotion.y); + } } if (eventWindow == None) return -1; xEvent->xmotion.window = eventWindow; - xEvent->xmotion.state = - convertModifierState(SDL_GetModState()) | pointerButtonState; + xEvent->xmotion.state = motionState; xEvent->xmotion.is_hint = HAS_EVENT_MASK(eventWindow, PointerMotionHintMask) ? NotifyHint : NotifyNormal; xEvent->xmotion.same_screen = True; + if (crossingQueued) { + appendPutBackEvent(display, xEvent); + return -1; + } break; case SDL_WINDOWEVENT: eventWindow = getWindowFromId(sdlEvent->window.windowID); @@ -1093,6 +1839,8 @@ int convertEvent(Display *display, GET_WINDOW_STRUCT(eventWindow)->h = (unsigned int) sdlEvent->window.data2; resizeWindowTexture(eventWindow); + clearWindowTreeWithoutExpose(display, eventWindow); + postFullWindowExpose(display, eventWindow); } } else { SDL_GetWindowSize( @@ -1138,6 +1886,25 @@ int convertEvent(Display *display, NotifyNormal, NotifyAncestor, convertModifierState(SDL_GetModState())); xEvent->xcrossing.time = sdlEvent->window.timestamp; + Bool queuedNestedLeaves = False; + if (type == LeaveNotify) { + queuedNestedLeaves = queueNestedPointerLeaves( + display, eventWindow, xEvent->xcrossing.state, + sdlEvent->window.timestamp); + } + /* Keep pointerHoverWindow in sync with the SDL-level crossing + * we just emitted; otherwise the next motion event's + * postPointerCrossingEvents would either fire a duplicate + * EnterNotify (state was None) or suppress a legitimate Enter + * after a leave/re-enter (state still pointed at the previous + * hover child). */ + lockActivePointerWindow(); + pointerHoverWindow = type == EnterNotify ? eventWindow : None; + unlockActivePointerWindow(); + if (queuedNestedLeaves) { + appendPutBackEvent(display, xEvent); + return -1; + } break; case SDL_WINDOWEVENT_FOCUS_GAINED: LOG("Window %d gained keyboard focus\n", sdlEvent->window.windowID); @@ -1272,21 +2039,41 @@ int convertEvent(Display *display, Window sdlWheelWindow = getWindowFromId(sdlEvent->wheel.windowID); xEvent->xbutton.root = SCREEN_WINDOW; int mx = 0, my = 0; - SDL_GetMouseState(&mx, &my); + /* SDL wheel events carry no coordinates. Use the injected + * position only for XTest-synthesized events (xtest.c tags + * them with wheel.which = SDL_TOUCH_MOUSEID); real hardware + * wheels keep the SDL_GetMouseState path so the user's + * physical cursor still wins after any XTest activity. The + * sentinel-based dispatch was a gemini-flagged fix to the + * earlier xtestHasInjectedPos-only check, which never reset + * and would have routed every later real wheel to the stale + * injected coords. */ + if (sdlEvent->wheel.which == SDL_TOUCH_MOUSEID && + replayTargetReadPointer(&mx, &my)) { + /* injected pos wins */ + } else { + SDL_GetMouseState(&mx, &my); + } xEvent->xbutton.time = sdlEvent->wheel.timestamp; translateSdlPointToRoot(display, sdlWheelWindow, mx, my, &xEvent->xbutton.x_root, &xEvent->xbutton.y_root); - eventWindow = selectPointerEventWindow( - display, xEvent->xbutton.root, xEvent->xbutton.x_root, - xEvent->xbutton.y_root, ButtonPressMask, - &xEvent->xbutton.subwindow, &xEvent->xbutton.x, - &xEvent->xbutton.y); + if (!routePointerGrabEvent( + display, xEvent->xbutton.root, xEvent->xbutton.x_root, + xEvent->xbutton.y_root, ButtonPressMask, &eventWindow, + &xEvent->xbutton.subwindow, &xEvent->xbutton.x, + &xEvent->xbutton.y)) { + eventWindow = selectPointerEventWindow( + display, xEvent->xbutton.root, xEvent->xbutton.x_root, + xEvent->xbutton.y_root, ButtonPressMask, + &xEvent->xbutton.subwindow, &xEvent->xbutton.x, + &xEvent->xbutton.y); + } if (eventWindow == None) return -1; xEvent->xbutton.window = eventWindow; - xEvent->xbutton.state = - convertModifierState(SDL_GetModState()) | pointerButtonState; + xEvent->xbutton.state = convertModifierState(SDL_GetModState()) | + pointerButtonStateSnapshot(); xEvent->xbutton.button = wheelButton; xEvent->xbutton.same_screen = True; break; @@ -1391,6 +2178,31 @@ int convertEvent(Display *display, default: if (sdlEvent->type >= SDL_USEREVENT && sdlEvent->type <= SDL_LASTEVENT) { + LOG("convertEvent USEREVENT code=%d\n", sdlEvent->user.code); + if (snapshotOwnsEventType(sdlEvent->type) && + sdlEvent->user.code == SNAPSHOT_EVENT_CODE) { + /* Smoke snapshot pump: we are on the main (X-client) + * thread here, which is the only place where + * SDL_GetWindowSurface is allowed. snapshotHandleEvent + * frees the path buffer and signals the replay thread + * so its synchronous wait can return. */ + snapshotHandleEvent(sdlEvent); + return -1; + } + if (snapshotOwnsEventType(sdlEvent->type) && + sdlEvent->user.code == RESIZE_EVENT_CODE) { + /* Replay-driven resize. SDL_SetWindowSize must run on + * the thread that created the window; on macOS that's + * the X-client main thread (the same one running + * here). Synchronous round-trip via the same condvar + * the snapshot path uses. */ + snapshotHandleResizeEvent(display, sdlEvent); + return -1; + } + if (sdlEvent->user.code == PRESENT_EVENT_CODE) { + drawWindowDataToScreen(); + return -1; + } if (sdlEvent->user.code == INTERNAL_EVENT_CODE) { XAnyEvent *allocEvent = sdlEvent->user.data1; eventWindow = (Window) sdlEvent->user.data2; @@ -1630,10 +2442,17 @@ int XNextEvent(Display *display, XEvent *event_return) * blocks on input. Mirror that here so accumulated drawing * reaches the screen before we go to sleep. */ drawWindowDataToScreen(); - if (SDL_WaitEvent(&event) != 1) { - LOG("SDL_WaitEvent failed: %s, retrying...\n", SDL_GetError()); - fflush(stderr); - continue; + if (SDL_PeepEvents(&event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, + SDL_LASTEVENT) != 1) { + pumpEventsSafe(); + if (SDL_PeepEvents(&event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, + SDL_LASTEVENT) != 1) { + int putBackCount = countPutBackEvents(display); + if (displayEventQueueLength(display) > putBackCount) + resetEventWakeups(display, putBackCount); + SDL_Delay(1); + continue; + } } /* Drain the wake-up byte the SDL filter wrote for this event so * a follow-up select(ConnectionNumber) reflects the real queue @@ -1676,7 +2495,14 @@ Bool enqueueEvent(Display *display, Window eventWindow, void *event) sdlEvent.user.data1 = event; sdlEvent.user.data2 = (void *) eventWindow; LOG("Enqueuing event\n"); - SDL_PushEvent(&sdlEvent); + int pushed = SDL_PushEvent(&sdlEvent); + if (pushed != 1) { + LOG("SDL_PushEvent failed/filtered: result=%d error=%s type=%d " + "window=%lu\n", + pushed, SDL_GetError(), ((XAnyEvent *) event)->type, + eventWindow); + return False; + } return True; } LOG("Failed to send event: SDL_RegisterEvents failed!"); @@ -1853,12 +2679,12 @@ static Bool enqueueAlias(Display *display, * and GravityNotify: each can be reported to the parent (SubstructureNotify) * and to the window itself (StructureNotify). * - * FANOUT_FAIL -- enqueue failed; `event` already freed, caller breaks. + * FANOUT_FAIL -- enqueue failed; event already freed, caller breaks. * FANOUT_CONSUMED -- delivered to parent only; caller must NOT set eventData * (ownership transferred to the queue). - * FANOUT_KEEP -- `event` still owned by caller; it must store it in + * FANOUT_KEEP -- event still owned by caller; it must store it in * eventData so the self delivery happens. The "event" - * field has been patched to `self`. */ + * field has been patched to self. */ typedef enum { FANOUT_FAIL, FANOUT_CONSUMED, FANOUT_KEEP } FanoutResult; static FanoutResult fanOutNotify(Display *display, @@ -1985,6 +2811,7 @@ Bool postEvent(Display *display, Window eventWindow, unsigned int eventId, ...) } case Expose: { SDL_Rect *exposeRect = va_arg(args, SDL_Rect *); + size_t exposeCount = va_arg(args, size_t); if (!HAS_EVENT_MASK(eventWindow, ExposureMask) || IS_INPUT_ONLY(eventWindow) || GET_WINDOW_STRUCT(eventWindow)->mapState != Mapped) { @@ -1995,19 +2822,26 @@ Bool postEvent(Display *display, Window eventWindow, unsigned int eventId, ...) GET_WINDOW_STRUCT(eventWindow)->mapState); SKIP } - XExposeEvent *event = malloc(sizeof(XExposeEvent)); - if (!event) + XEvent event; + memset(&event, 0, sizeof(event)); + event.xexpose.type = eventId; + event.xexpose.serial = GET_DISPLAY(display)->request; + event.xexpose.send_event = False; + event.xexpose.display = display; + event.xexpose.window = eventWindow; + event.xexpose.x = exposeRect->x; + event.xexpose.y = exposeRect->y; + event.xexpose.width = exposeRect->w; + event.xexpose.height = exposeRect->h; + event.xexpose.count = exposeCount; + /* Expose bursts are generated internally; queue them directly so Xt + * exposure compression can see the complete burst. Drain already + * queued SDL events first so this Expose does not jump ahead of older + * input/client/window events. */ + drainSdlEventsToPutBack(display); + if (!appendPutBackEvent(display, &event)) break; - event->type = eventId; - event->send_event = False; - event->display = display; - event->window = eventWindow; - event->x = exposeRect->x; - event->y = exposeRect->y; - event->width = exposeRect->w; - event->height = exposeRect->h; - event->count = va_arg(args, size_t); - eventData = event; + eventNeeded = False; break; } case ConfigureRequest: { @@ -2229,6 +3063,27 @@ Bool postEvent(Display *display, Window eventWindow, unsigned int eventId, ...) eventData = event; break; } + case GraphicsExpose: { + SDL_Rect *exposeRect = va_arg(args, SDL_Rect *); + size_t exposeCount = va_arg(args, size_t); + XGraphicsExposeEvent *event = malloc(sizeof(XGraphicsExposeEvent)); + if (!event) + break; + event->type = eventId; + event->serial = lastEventSerial; + event->send_event = False; + event->display = display; + event->drawable = eventWindow; + event->x = exposeRect ? exposeRect->x : 0; + event->y = exposeRect ? exposeRect->y : 0; + event->width = exposeRect ? exposeRect->w : 0; + event->height = exposeRect ? exposeRect->h : 0; + event->count = exposeCount > 0 ? (int) exposeCount - 1 : 0; + event->major_code = va_arg(args, int); + event->minor_code = va_arg(args, int); + eventData = event; + break; + } case ColormapNotify: { if (!HAS_EVENT_MASK(eventWindow, ColormapChangeMask)) SKIP XColormapEvent *event = malloc(sizeof(XColormapEvent)); @@ -2306,9 +3161,6 @@ Bool postEvent(Display *display, Window eventWindow, unsigned int eventId, ...) case KeymapNotify: // memcpy(&xEvent->xexpose, allocEvent, // sizeof(XExposeEvent)); break; - case GraphicsExpose: - // memcpy(&xEvent->xgraphicsexpose, allocEvent, - // sizeof(XGraphicsExposeEvent)); break; case ResizeRequest: // memcpy(&xEvent->xresizerequest, allocEvent, // sizeof(XResizeRequestEvent)); break; @@ -2457,16 +3309,11 @@ static Bool checkTypedEvent(Display *display, for (int i = 0; i < qlen; i++) { READ_EVENT_IN_PIPE(display); } - LOG("FOUND %d events in SDL queue\n", qlen); Bool foundMatch = False; for (int i = 0; i < qlen; i++) { XEvent convertedEvent; - if (convertEvent(display, &tmp[i], &convertedEvent, True) != 0) { - LOG("i = %d, SDL event_type = %d skipped\n", i, tmp[i].type); + if (convertEvent(display, &tmp[i], &convertedEvent, True) != 0) continue; - } - LOG("i = %d, SDL event_type = %d, X event_type = %d\n", i, - tmp[i].type, convertedEvent.type); if (!foundMatch && predicate(w, type, mask, &convertedEvent)) { memcpy(event, &convertedEvent, sizeof(XEvent)); foundMatch = True; diff --git a/src/events.h b/src/events.h index 8688bcf..98f8290 100644 --- a/src/events.h +++ b/src/events.h @@ -6,6 +6,23 @@ #define SEND_EVENT_CODE 1 #define INTERNAL_EVENT_CODE 2 +/* Snapshot the replay target window's backing surface to a path. Carried on + * SDL_USEREVENT.user.data1 (path, allocated by replay thread, freed by the + * main-thread handler). Used by the in-process smoke-snapshot path to + * bypass macOS screencapture, which deactivates NSApp briefly and stalls + * the SDL event pump just long enough that synthetic input goes + * undelivered. */ +#define SNAPSHOT_EVENT_CODE 3 +/* Resize the replay target window. user.data1 carries width, user.data2 + * carries height, each as intptr_t. Runs synchronously on the main thread + * (SDL_SetWindowSize is main-thread only on macOS). Used by the replay + * engine's resize command so the smoke can exercise ViolaWWW's + * ConfigureNotify reflow without depending on the OS window manager. */ +#define RESIZE_EVENT_CODE 4 +/* Flush pending top-level backing textures to their SDL windows. Posted by the + * drawing layer after X drawing operations that happen away from a blocking + * XNextEvent/XFlush path, such as Motif popup menu expose redraws. */ +#define PRESENT_EVENT_CODE 5 #define HAS_EVENT_MASK(window, mask) \ ((GET_WINDOW_STRUCT(window)->eventMask & mask) == mask) @@ -14,6 +31,7 @@ int initEventPipe(Display *display); void closeEventPipe(Display *display); void captureMainEventThreadIfUnset(void); void releaseMainEventThread(void); +void wakeEventPipeForExternalEvent(Display *display); unsigned int convertModifierState(Uint16 mod); Bool postEvent(Display *display, Window eventWindow, unsigned int eventId, ...); Bool postReparentUnmapNotify(Display *display, @@ -27,6 +45,22 @@ Bool postCrossingEvent(Display *display, int mode, int detail, unsigned int state); +Bool postFocusEvent(Display *display, Window window, int type, int detail); + +/* Focus-target classification. The window leaf stays collapsed to None + * for routing, but postFocusChange needs the distinction to pick + * NotifyDetailNone vs NotifyPointerRoot on the root events per Xlib 10.7. */ +typedef enum { + FocusKindNone, + FocusKindPointerRoot, + FocusKindWindow, +} FocusKind; + +void postFocusChange(Display *display, + FocusKind oldKind, + Window oldFocus, + FocusKind newKind, + Window newFocus); void postExposeEvent(Display *display, Window window, const SDL_Rect *damagedAreaList, @@ -35,5 +69,12 @@ void postExposeEventsForMappedChildren(Display *display, Window window, const SDL_Rect *damagedAreaList, size_t numAreas); +void postSyntheticWindowResize(Display *display, + Window eventWindow, + int width, + int height); +void clearActivePointerWindow(void); +void clearPointerStateForWindow(Window window); +void releaseButtonGrabsForWindow(Window window); #endif /* _EVENTS_H_ */ diff --git a/src/extension.c b/src/extension.c index 44b1396..0a3a5e6 100644 --- a/src/extension.c +++ b/src/extension.c @@ -4,6 +4,7 @@ #include #include #include +#include #include "extension.h" #include "util.h" @@ -72,6 +73,15 @@ Bool XQueryExtension(Display *display, *first_error_return = 128; return True; } + if (name && !strcmp(name, XTestExtensionName)) { + if (major_opcode_return) + *major_opcode_return = 132; + if (first_event_return) + *first_event_return = 0; + if (first_error_return) + *first_error_return = 0; + return True; + } LOG("Ignoring unsupported extension probe: %s\n", name ? name : "(null)"); if (major_opcode_return) { diff --git a/src/font-6x13-bitmap.h b/src/font-6x13-bitmap.h new file mode 100644 index 0000000..eb60e87 --- /dev/null +++ b/src/font-6x13-bitmap.h @@ -0,0 +1,258 @@ +/* Generated by scripts/embed-bdf-font.py. Do not edit. */ +static const unsigned char EMBEDDED_FIXED_BITMAP_ROWS[128][13] = { + {0x00, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0xf0, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x70, 0xf8, 0x70, 0x20, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0xa8, 0x50, 0xa8, 0x50, 0xa8, 0x50, 0xa8, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0xa0, 0xa0, 0xe0, 0xa0, 0xa0, 0x70, 0x20, 0x20, + 0x20}, + {0x00, 0x00, 0x00, 0x00, 0xe0, 0x80, 0xc0, 0x80, 0xf0, 0x40, 0x60, 0x40, + 0x40}, + {0x00, 0x00, 0x00, 0x00, 0x70, 0x80, 0x80, 0x70, 0x70, 0x48, 0x70, 0x50, + 0x48}, + {0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x80, 0xe0, 0x70, 0x40, 0x60, 0x40, + 0x40}, + {0x00, 0x00, 0x00, 0x00, 0x60, 0x90, 0x90, 0x60, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x88, 0xc8, 0xa8, 0x98, 0x88, 0x40, 0x40, 0x40, + 0x78}, + {0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x50, 0x20, 0x00, 0xf8, 0x20, 0x20, + 0x20}, + {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0xe0, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x20, 0x20, 0x20, 0x20, + 0x20}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3c, 0x20, 0x20, 0x20, 0x20, + 0x20}, + {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x3c, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0xfc, 0x20, 0x20, 0x20, 0x20, + 0x20}, + {0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, + 0x00}, + {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x3c, 0x20, 0x20, 0x20, 0x20, + 0x20}, + {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0xe0, 0x20, 0x20, 0x20, 0x20, + 0x20}, + {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0xfc, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x20, 0x20, 0x20, 0x20, + 0x20}, + {0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, + 0x20}, + {0x00, 0x00, 0x00, 0x08, 0x10, 0x20, 0x40, 0x20, 0x10, 0x08, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x20, 0x40, 0x80, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x50, 0x50, 0x50, 0x90, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xf8, 0x20, 0xf8, 0x80, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x30, 0x48, 0x40, 0xf0, 0x20, 0xf0, 0xa8, 0xe0, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0x50, 0x50, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x50, 0x50, 0xf8, 0x50, 0xf8, 0x50, 0x50, 0x00, 0x00, + 0x00}, + {0x00, 0x20, 0x70, 0xa0, 0xa0, 0x70, 0x28, 0x28, 0x70, 0x20, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x48, 0xa8, 0x50, 0x10, 0x20, 0x40, 0x50, 0xa8, 0x90, 0x00, + 0x00}, + {0x00, 0x00, 0x40, 0xa0, 0xa0, 0x40, 0xa0, 0x98, 0x90, 0x68, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x30, 0x20, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x10, 0x20, 0x20, 0x40, 0x40, 0x40, 0x20, 0x20, 0x10, 0x00, + 0x00}, + {0x00, 0x00, 0x40, 0x20, 0x20, 0x10, 0x10, 0x10, 0x20, 0x20, 0x40, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x20, 0xa8, 0xf8, 0x70, 0xf8, 0xa8, 0x20, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x20, 0x20, 0xf8, 0x20, 0x20, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x20, 0x40, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x70, 0x20, + 0x00}, + {0x00, 0x00, 0x08, 0x08, 0x10, 0x10, 0x20, 0x40, 0x40, 0x80, 0x80, 0x00, + 0x00}, + {0x00, 0x00, 0x20, 0x50, 0x88, 0x88, 0x88, 0x88, 0x88, 0x50, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0x20, 0x60, 0xa0, 0x20, 0x20, 0x20, 0x20, 0x20, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x88, 0x08, 0x10, 0x20, 0x40, 0x80, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0xf8, 0x08, 0x10, 0x20, 0x70, 0x08, 0x08, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x10, 0x10, 0x30, 0x50, 0x50, 0x90, 0xf8, 0x10, 0x10, 0x00, + 0x00}, + {0x00, 0x00, 0xf8, 0x80, 0x80, 0xb0, 0xc8, 0x08, 0x08, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x80, 0x80, 0xf0, 0x88, 0x88, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0xf8, 0x08, 0x10, 0x10, 0x20, 0x20, 0x40, 0x40, 0x40, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x70, 0x88, 0x88, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x78, 0x08, 0x08, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x20, 0x70, 0x20, 0x00, 0x00, 0x20, 0x70, 0x20, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x20, 0x70, 0x20, 0x00, 0x00, 0x30, 0x20, 0x40, + 0x00}, + {0x00, 0x00, 0x08, 0x10, 0x20, 0x40, 0x80, 0x40, 0x20, 0x10, 0x08, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x00, 0x00, 0xf8, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x80, 0x40, 0x20, 0x10, 0x08, 0x10, 0x20, 0x40, 0x80, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x88, 0x08, 0x10, 0x20, 0x20, 0x00, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x88, 0x98, 0xa8, 0xa8, 0xb0, 0x80, 0x78, 0x00, + 0x00}, + {0x00, 0x00, 0x20, 0x50, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0xf0, 0x48, 0x48, 0x48, 0x70, 0x48, 0x48, 0x48, 0xf0, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x80, 0x80, 0x80, 0x80, 0x80, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0xf0, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0x48, 0xf0, 0x00, + 0x00}, + {0x00, 0x00, 0xf8, 0x80, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0xf8, 0x80, 0x80, 0x80, 0xf0, 0x80, 0x80, 0x80, 0x80, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x80, 0x80, 0x80, 0x98, 0x88, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0xf8, 0x88, 0x88, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x38, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x90, 0x60, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0xd8, 0xa8, 0xa8, 0x88, 0x88, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0xc8, 0xc8, 0xa8, 0xa8, 0x98, 0x98, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0xf0, 0x88, 0x88, 0x88, 0xf0, 0x80, 0x80, 0x80, 0x80, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0xa8, 0x70, 0x08, + 0x00}, + {0x00, 0x00, 0xf0, 0x88, 0x88, 0x88, 0xf0, 0xa0, 0x90, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x88, 0x80, 0x80, 0x70, 0x08, 0x08, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0xf8, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0x50, 0x50, 0x50, 0x20, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0xa8, 0xa8, 0xa8, 0xd8, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0x50, 0x50, 0x20, 0x50, 0x50, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x88, 0x88, 0x50, 0x50, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0xf8, 0x08, 0x10, 0x10, 0x20, 0x40, 0x40, 0x80, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x40, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x80, 0x80, 0x40, 0x40, 0x20, 0x10, 0x10, 0x08, 0x08, 0x00, + 0x00}, + {0x00, 0x00, 0x70, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x20, 0x50, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, + 0x00}, + {0x00, 0x00, 0x30, 0x10, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x08, 0x78, 0x88, 0x88, 0x78, 0x00, + 0x00}, + {0x00, 0x00, 0x80, 0x80, 0x80, 0xf0, 0x88, 0x88, 0x88, 0x88, 0xf0, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x80, 0x80, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x08, 0x08, 0x08, 0x78, 0x88, 0x88, 0x88, 0x88, 0x78, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0xf8, 0x80, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x30, 0x48, 0x40, 0x40, 0xf0, 0x40, 0x40, 0x40, 0x40, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x78, 0x08, 0x88, + 0x70}, + {0x00, 0x00, 0x80, 0x80, 0x80, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x20, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x10, 0x00, 0x30, 0x10, 0x10, 0x10, 0x10, 0x90, 0x90, + 0x60}, + {0x00, 0x00, 0x80, 0x80, 0x80, 0x90, 0xa0, 0xc0, 0xa0, 0x90, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x60, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0xd0, 0xa8, 0xa8, 0xa8, 0xa8, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x88, 0x88, 0x88, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x88, 0x88, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x88, 0x88, 0x88, 0xf0, 0x80, 0x80, + 0x80}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x88, 0x88, 0x88, 0x78, 0x08, 0x08, + 0x08}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0xb0, 0xc8, 0x80, 0x80, 0x80, 0x80, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x88, 0x60, 0x10, 0x88, 0x70, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x40, 0x40, 0xf0, 0x40, 0x40, 0x40, 0x48, 0x30, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x88, 0x98, 0x68, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x50, 0x50, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0xa8, 0xa8, 0xa8, 0x50, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x50, 0x20, 0x20, 0x50, 0x88, 0x00, + 0x00}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0x88, 0x88, 0x88, 0x98, 0x68, 0x08, 0x88, + 0x70}, + {0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0x10, 0x20, 0x40, 0x80, 0xf8, 0x00, + 0x00}, + {0x00, 0x00, 0x18, 0x20, 0x20, 0x20, 0xc0, 0x20, 0x20, 0x20, 0x18, 0x00, + 0x00}, + {0x00, 0x00, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, + 0x00}, + {0x00, 0x00, 0xc0, 0x20, 0x20, 0x20, 0x18, 0x20, 0x20, 0x20, 0xc0, 0x00, + 0x00}, + {0x00, 0x00, 0x48, 0xa8, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00}, + {0xa8, 0x50, 0xa8, 0x50, 0xa8, 0x50, 0xa8, 0x50, 0xa8, 0x50, 0xa8, 0x50, + 0xa8}}; diff --git a/src/font.c b/src/font.c index a010b7a..c5bb556 100644 --- a/src/font.c +++ b/src/font.c @@ -1,7 +1,9 @@ #include "X11/Xatom.h" +#include #include #include #include +#include #include #include #include @@ -16,6 +18,7 @@ #include "atoms.h" #include "drawing.h" #include "display.h" +#include "events.h" #include "gc.h" #include "util.h" #include "font.h" @@ -24,6 +27,7 @@ typedef struct { char *filePath; char *XLFName; Bool fixedWidth; + Bool asciiMetrics; } FontCacheEntry; typedef struct { @@ -34,6 +38,7 @@ typedef struct { short coreAscent; short coreDescent; short coreWidth; + Bool useFixedBitmap; } CompatFont; #define GET_FONT_RESOURCE(fontXID) ((CompatFont *) GET_XID_VALUE(fontXID)) @@ -42,6 +47,38 @@ typedef struct { #define DEFAULT_FONT_SIZE 10 #define MIN_FONT_SIZE 6 #define MAX_FONT_SIZE 72 +#define ASCII_RENDER_PROBE_TEXT "Aa0 " +#define FIXED_BITMAP_WIDTH 6 +#define FIXED_BITMAP_HEIGHT 13 +/* Match the core metrics reported for Xvfb's "fixed" alias. The bundled + * 6x13 bitmap has 13 rows, so drawing it in an 11/2 box keeps every glyph + * pixel inside the row extent clients use when clearing scrolled text. */ +#define FIXED_BITMAP_ASCENT 11 +#define FIXED_BITMAP_DESCENT 2 +#define FIXED_BITMAP_CHAR_COUNT 128 +/* Minimum decoded glyphs before we declare the BDF usable. ASCII printable + * is 0x20..0x7E (95 glyphs); requiring 64 keeps a partially trimmed file + * out of the renderer while still tolerating glyphs that are intentionally + * blank (e.g. space). */ +#define FIXED_BITMAP_MIN_GLYPHS 64 + +typedef struct { + Bool loaded; + Bool available; + unsigned char rows[FIXED_BITMAP_CHAR_COUNT][FIXED_BITMAP_HEIGHT]; +} FixedBitmapFont; + +static FixedBitmapFont fixedBitmapFont; +static SDL_SpinLock fixedBitmapFontLock; + +static void finishTextDamage(Display *display, + Drawable drawable, + const SDL_Rect *damage) +{ + if (damage && IS_TYPE(drawable, WINDOW)) + postExposeEventsForMappedChildren(display, drawable, damage, 1); + presentDrawableIfVisible(drawable); +} /* Project-bundled "fonts" wins for self-contained checkouts; the remaining * entries let normal Xlib clients pick up host-system fonts without having @@ -63,6 +100,11 @@ static const char *DEFAULT_FONT_SEARCH_PATHS[] = { Array *fontSearchPaths = NULL; Array *fontCache = NULL; +/* coreFontMetricsForName needs to consult loadFixedBitmapFont() (defined + * further down) to decide whether to advertise the bitmap-font metrics. */ +static Bool loadFixedBitmapFont(void); +static Array *fontAliasNames = NULL; + // Check if the given path points to an existing directory Bool checkFontPath(const char *path) { @@ -126,9 +168,58 @@ Bool fontCmp(void *entry, void *fontWildCard) return matchWildcard(fontWildCard, ((FontCacheEntry *) entry)->XLFName); } +static Bool isSupportedFontFileName(const char *name) +{ + if (!name) + return False; + size_t len = strlen(name); + static const char *const extensions[] = {".ttf", ".ttc", ".otf", ".dfont", + ".bdf"}; + for (size_t i = 0; i < ARRAY_LENGTH(extensions); i++) { + size_t extLen = strlen(extensions[i]); + if (len <= extLen) + continue; + if (!strcasecmp(&name[len - extLen], extensions[i])) + return True; + } + return False; +} + +static Bool fontHasPositiveAdvanceForChar(TTF_Font *font, Uint16 ch) +{ + int minX; + int maxX; + int minY; + int maxY; + int advance; + return TTF_GlyphIsProvided(font, ch) && + TTF_GlyphMetrics(font, ch, &minX, &maxX, &minY, &maxY, &advance) != + -1 && + advance > 0; +} + +static Bool fontHasAsciiTextMetrics(TTF_Font *font) +{ + if (!fontHasPositiveAdvanceForChar(font, (Uint16) ' ') || + !fontHasPositiveAdvanceForChar(font, (Uint16) '0') || + !fontHasPositiveAdvanceForChar(font, (Uint16) 'A') || + !fontHasPositiveAdvanceForChar(font, (Uint16) 'a')) { + return False; + } + + SDL_Color black = {0, 0, 0, 255}; + SDL_Surface *surface = + TTF_RenderUTF8_Solid(font, ASCII_RENDER_PROBE_TEXT, black); + if (!surface) + return False; + Bool renderable = surface->w > 0 && surface->h > 0; + SDL_FreeSurface(surface); + return renderable; +} + Bool updateFontCache() { - size_t i, entryNameLen; + size_t i; size_t fontCacheIndex = 0; DIR *fontDirectory; struct dirent *entry; @@ -145,9 +236,7 @@ Bool updateFontCache() // We add all missing fonts to the cache, swapping their position to // the front. We can be sure, that the fonts with an index lower // than fontCacheIndex are valid. - entryNameLen = strlen(entry->d_name); - if (entryNameLen > 4 && - !strncmp(&entry->d_name[entryNameLen - 4], ".ttf", 4)) { + if (isSupportedFontFileName(entry->d_name)) { ssize_t index = findInArrayNCmp(fontCache, entry->d_name, fontCacheIndex, &fontCacheEntryFileNameCmp); @@ -169,6 +258,8 @@ Bool updateFontCache() fontCacheEntry->filePath = strdup(pathBuffer); fontCacheEntry->XLFName = getFontXLFDName(font); fontCacheEntry->fixedWidth = TTF_FontFaceIsFixedWidth(font); + fontCacheEntry->asciiMetrics = + fontHasAsciiTextMetrics(font); TTF_CloseFont(font); if (!fontCacheEntry->filePath || !fontCacheEntry->XLFName) { if (fontCacheEntry->filePath) @@ -234,9 +325,7 @@ Bool initFontStorage() insertArray(fontSearchPaths, (char *) path); // Count the font files in the directory while ((entry = readdir(fontDirectory))) { - size_t entryNameLen = strlen(entry->d_name); - if (entryNameLen > 4 && - !strncmp(&entry->d_name[entryNameLen - 4], ".ttf", 4)) { + if (isSupportedFontFileName(entry->d_name)) { fontCount++; } } @@ -251,6 +340,21 @@ Bool initFontStorage() if (!initArray(fontCache, fontCount)) { return False; } + fontAliasNames = malloc(sizeof(Array)); + if (!fontAliasNames) { + freeArray(fontCache); + free(fontCache); + fontCache = NULL; + return False; + } + if (!initArray(fontAliasNames, 0)) { + free(fontAliasNames); + fontAliasNames = NULL; + freeArray(fontCache); + free(fontCache); + fontCache = NULL; + return False; + } return updateFontCache(); } @@ -281,6 +385,14 @@ void freeFontStorage() free(fontCache); fontCache = NULL; } + if (fontAliasNames) { + while (fontAliasNames->length > 0) { + free(removeArray(fontAliasNames, 0, False)); + } + freeArray(fontAliasNames); + free(fontAliasNames); + fontAliasNames = NULL; + } } static int requestedFontSize(const char *name); @@ -320,17 +432,18 @@ static Bool isFontAlias(const char *name) * after exact match misses. -adobe-times- is not fixed-width, but * x11perf requests it for TR10/TR24 tests and would otherwise abort * on XOpenFont(None). */ - return !strcmp(name, "fixed") || !strcmp(name, "cursor") || - !strcmp(name, "6x13") || !strcmp(name, "8x13") || - !strcmp(name, "7x14") || !strcmp(name, "9x13") || - !strcmp(name, "9x15") || !strcmp(name, "9x18") || - !strcmp(name, "12x24") || !strncmp(name, "-misc-fixed-", 12) || + return !strcmp(name, "fixed") || !strcmp(name, "variable") || + !strcmp(name, "cursor") || !strcmp(name, "6x13") || + !strcmp(name, "8x13") || !strcmp(name, "7x14") || + !strcmp(name, "9x13") || !strcmp(name, "9x15") || + !strcmp(name, "9x18") || !strcmp(name, "12x24") || + !strncmp(name, "-misc-fixed-", 12) || !strncmp(name, "-jis-fixed-", 11) || containsIgnoreCase(name, "helvetica") || + containsIgnoreCase(name, "helv") || containsIgnoreCase(name, "courier") || containsIgnoreCase(name, "adobe-times") || - (containsIgnoreCase(name, "times") && - requestedFontSize(name) != 14) || + containsIgnoreCase(name, "times") || ((strstr(name, "-medium-r-") || strstr(name, "-bold-r-")) && strstr(name, "-p-")); } @@ -447,6 +560,30 @@ static Bool parsePositiveInt(const char *text, int *valueReturn) return parsePositiveIntBounded(text, MAX_FONT_SIZE, valueReturn); } +static int wildcardStylePixelSize(const char *name) +{ + static const char *const styleMarkers[] = {"-r-*-", "-i-*-", "-o-*-"}; + for (size_t i = 0; i < ARRAY_LENGTH(styleMarkers); i++) { + const char *start = strstr(name, styleMarkers[i]); + if (!start) + continue; + start += strlen(styleMarkers[i]); + const char *end = strchr(start, '-'); + if (!end || end == start) + continue; + size_t len = (size_t) (end - start); + if (len >= 16) + continue; + char buffer[16]; + int pixelSize = 0; + memcpy(buffer, start, len); + buffer[len] = '\0'; + if (parsePositiveInt(buffer, &pixelSize)) + return clampFontSize(pixelSize); + } + return 0; +} + static int requestedFontSize(const char *name) { if (!name) @@ -486,6 +623,10 @@ static int requestedFontSize(const char *name) } } + int wildcardPixelSize = wildcardStylePixelSize(name); + if (wildcardPixelSize) + return wildcardPixelSize; + const char *fieldStart = name; int field = name[0] == '-' ? 0 : 1; int pointSizeFallback = 0; @@ -533,6 +674,23 @@ static int requestedFontSize(const char *name) return DEFAULT_FONT_SIZE; } +/* Match only the exact Motif fallback XLFD shape + * "--times-medium-r-normal--14-...-iso8859-1[-encoding]". Substring + * matches on "times" and "iso8859-1" were too permissive: legitimate + * proportional Times-Bold-Italic 14pt requests got rerouted to the 6x13 + * fixed-width font. Anchor on the dashed field markers so non-medium, + * non-roman, non-normal, or wildcarded foundry-but-not-times XLFDs miss. */ +static Bool isMotifTimesIso14Fallback(const char *name) +{ + if (!name) + return False; + if (!containsIgnoreCase(name, "-times-medium-r-normal-")) + return False; + if (!containsIgnoreCase(name, "-iso8859-1")) + return False; + return requestedFontSize(name) == 14; +} + static Bool coreFontMetricsForName(const char *name, short *ascent, short *descent, @@ -545,31 +703,47 @@ static Bool coreFontMetricsForName(const char *name, * fontLists; reporting smaller TTF metrics lets widgets pack too many * text rows compared with native libX11. */ - if (!strcmp(name, "fixed") || !strcmp(name, "cursor") || - !strcmp(name, "6x13")) { + /* Only advertise bitmap-font metrics when the BDF actually loaded; + * otherwise the renderer falls back to TTF and the layout engine + * would lay out at 11/2 while glyphs draw at TTF size, overlapping + * adjacent lines. */ + Bool haveBitmap = loadFixedBitmapFont(); + if (haveBitmap && (!strcmp(name, "fixed") || !strcmp(name, "cursor") || + !strcmp(name, "6x13"))) { *ascent = 11; *descent = 2; *width = 6; return True; } - if (!strcmp(name, "7x14")) { + if (haveBitmap && !strcmp(name, "7x14")) { *ascent = 12; *descent = 2; *width = 7; return True; } - if (!strcmp(name, "9x18")) { + if (haveBitmap && !strcmp(name, "9x18")) { *ascent = 14; *descent = 4; *width = 9; return True; } - if (!strcmp(name, "12x24")) { + if (haveBitmap && !strcmp(name, "12x24")) { *ascent = 22; *descent = 2; *width = 12; return True; } + /* thentenaar/motif's hellomotifi18n demo asks Mrm for + * -*-times-medium-r-normal--14-*-iso8859-1. The native Xvfb baseline on + * node11 does not have that font and Motif falls back to the default + * core font. Match that geometry only when we can actually render to + * it via the BDF; without BDF, we'd lie to the layout engine. */ + if (haveBitmap && isMotifTimesIso14Fallback(name)) { + *ascent = 11; + *descent = 2; + *width = 6; + return True; + } /* XLFD family names are case-insensitive per the X11 spec, so * "-Helvetica-" / "-HELVETICA-" must take the same override path as * the lowercase form. */ @@ -590,6 +764,126 @@ static Bool coreFontMetricsForName(const char *name, return False; } +static Bool usesFixedFallbackFont(const char *name) +{ + if (!name) + return False; + if (!strcmp(name, "fixed") || !strcmp(name, "cursor") || + !strcmp(name, "6x13")) + return True; + return isMotifTimesIso14Fallback(name); +} + +/* Treat as a bitmap row only when the line is a single hex token of the + * expected 1-byte width. Rejects COMMENT lines that happen to fall inside a + * BITMAP block and tails of fgets-truncated long lines, both of which would + * otherwise be misread as glyph pixel data. */ +static Bool isFixedBitmapRowLine(const char *line) +{ + while (*line == ' ' || *line == '\t') + line++; + int hexCount = 0; + while (line[hexCount] && isxdigit((unsigned char) line[hexCount])) + hexCount++; + if (hexCount == 0 || hexCount > 2) + return False; + for (const char *p = line + hexCount; *p; p++) { + if (*p != '\r' && *p != '\n' && *p != ' ' && *p != '\t') + return False; + } + return True; +} + +#include "font-6x13-bitmap.h" + +static void useEmbeddedFixedBitmap(void) +{ + /* The renderer reads fixedBitmapFont.rows directly, so a one-shot + * memcpy from the generated table is enough. Same width / height / + * char-count contract as the BDF reader below. */ + memcpy(fixedBitmapFont.rows, EMBEDDED_FIXED_BITMAP_ROWS, + sizeof(EMBEDDED_FIXED_BITMAP_ROWS)); + fixedBitmapFont.available = True; + fixedBitmapFont.loaded = True; +} + +static Bool loadFixedBitmapFont(void) +{ + SDL_AtomicLock(&fixedBitmapFontLock); + if (fixedBitmapFont.loaded) { + Bool result = fixedBitmapFont.available; + SDL_AtomicUnlock(&fixedBitmapFontLock); + return result; + } + + /* Prefer an external BDF when LIBX11_COMPAT_FONT_DIR points at one; + * this lets a downstream user swap in a different fixed font without + * recompiling. Otherwise fall through to the in-tree embedded font + * generated from 6x13.bdf so CI hosts and packagers do not need to + * stage a separate data file. */ + char path[PATH_MAX]; + const char *fontDir = getenv("LIBX11_COMPAT_FONT_DIR"); + FILE *fp = NULL; + if (fontDir && fontDir[0] != '\0') { + snprintf(path, sizeof(path), "%s/6x13.bdf", fontDir); + fp = fopen(path, "r"); + } + if (!fp) { + useEmbeddedFixedBitmap(); + SDL_AtomicUnlock(&fixedBitmapFontLock); + return True; + } + + int currentEncoding = -1; + int bitmapRow = -1; + int glyphsDecoded = 0; + Bool glyphHasRows = False; + char line[256]; + while (fgets(line, sizeof(line), fp)) { + if (!strncmp(line, "STARTCHAR ", 10)) { + currentEncoding = -1; + bitmapRow = -1; + glyphHasRows = False; + continue; + } + if (!strncmp(line, "ENCODING ", 9)) { + /* strtol over atoi so a malformed BDF line cannot quietly + * decode to 0; .ci/check-security.sh also bans atoi. */ + char *end = NULL; + long enc = strtol(&line[9], &end, 10); + currentEncoding = + end != &line[9] && enc >= 0 && enc <= INT_MAX ? (int) enc : -1; + continue; + } + if (!strncmp(line, "BITMAP", 6)) { + Bool encodingInRange = currentEncoding >= 0 && + currentEncoding < FIXED_BITMAP_CHAR_COUNT; + bitmapRow = encodingInRange ? 0 : -1; + continue; + } + if (!strncmp(line, "ENDCHAR", 7)) { + if (glyphHasRows) + glyphsDecoded++; + bitmapRow = -1; + glyphHasRows = False; + continue; + } + if (bitmapRow >= 0 && bitmapRow < FIXED_BITMAP_HEIGHT && + isFixedBitmapRowLine(line)) { + unsigned long bits = strtoul(line, NULL, 16); + fixedBitmapFont.rows[currentEncoding][bitmapRow++] = + (unsigned char) bits; + glyphHasRows = True; + } + } + fclose(fp); + fixedBitmapFont.available = glyphsDecoded >= FIXED_BITMAP_MIN_GLYPHS; + fixedBitmapFont.loaded = True; + Bool result = fixedBitmapFont.available; + SDL_AtomicUnlock(&fixedBitmapFontLock); + return result; +} + static FontCacheEntry *adoptProbePath(const char *path) { for (size_t i = 0; i < fontCache->length; i++) { @@ -609,6 +903,7 @@ static FontCacheEntry *adoptProbePath(const char *path) entry->filePath = strdup(path); entry->XLFName = getFontXLFDName(font); entry->fixedWidth = TTF_FontFaceIsFixedWidth(font); + entry->asciiMetrics = fontHasAsciiTextMetrics(font); TTF_CloseFont(font); if (!entry->filePath || !entry->XLFName || !insertArray(fontCache, entry)) { free(entry->filePath); @@ -623,39 +918,54 @@ static FontCacheEntry *findProbeFont(const char *const *paths, size_t count) { for (size_t i = 0; i < count; i++) { FontCacheEntry *entry = adoptProbePath(paths[i]); - if (entry) + if (entry && entry->asciiMetrics) + return entry; + } + for (size_t i = 0; i < fontCache->length; i++) { + FontCacheEntry *entry = fontCache->array[i]; + if (!entry->fixedWidth && entry->asciiMetrics) return entry; } for (size_t i = 0; i < fontCache->length; i++) { FontCacheEntry *entry = fontCache->array[i]; - if (!entry->fixedWidth) + if (entry->asciiMetrics) return entry; } - return fontCache->length > 0 ? fontCache->array[0] : NULL; + return NULL; } static FontCacheEntry *findAliasedFixedWidthFont(void) { + for (size_t i = 0; i < ARRAY_LENGTH(MONOSPACE_PROBE_PATHS); i++) { + FontCacheEntry *entry = adoptProbePath(MONOSPACE_PROBE_PATHS[i]); + if (entry && entry->asciiMetrics) + return entry; + } for (size_t i = 0; i < fontCache->length; i++) { FontCacheEntry *entry = fontCache->array[i]; - if (entry->fixedWidth) + if (entry->fixedWidth && entry->asciiMetrics) return entry; } - for (size_t i = 0; i < ARRAY_LENGTH(MONOSPACE_PROBE_PATHS); i++) { - FontCacheEntry *entry = adoptProbePath(MONOSPACE_PROBE_PATHS[i]); - if (entry) + for (size_t i = 0; i < fontCache->length; i++) { + FontCacheEntry *entry = fontCache->array[i]; + if (entry->asciiMetrics) return entry; } - return fontCache->length > 0 ? fontCache->array[0] : NULL; + return NULL; } static FontCacheEntry *findAliasedFontForName(const char *name) { + if (usesFixedFallbackFont(name)) + return findAliasedFixedWidthFont(); + if (!strcmp(name, "variable")) + return findProbeFont(SANS_PROBE_PATHS, ARRAY_LENGTH(SANS_PROBE_PATHS)); if (containsIgnoreCase(name, "times") || containsIgnoreCase(name, "adobe-times")) return findProbeFont(SERIF_PROBE_PATHS, ARRAY_LENGTH(SERIF_PROBE_PATHS)); if (containsIgnoreCase(name, "helvetica") || + containsIgnoreCase(name, "helv") || containsIgnoreCase(name, "lucida") || containsIgnoreCase(name, "arial")) { if (containsIgnoreCase(name, "bold")) { @@ -675,6 +985,89 @@ static FontCacheEntry *findAliasedFontForName(const char *name) return findAliasedFixedWidthFont(); } +static Bool fontCanRenderText(TTF_Font *font) +{ + if (!font) + return False; + SDL_Color black = {0, 0, 0, 255}; + SDL_Surface *surface = + TTF_RenderUTF8_Solid(font, ASCII_RENDER_PROBE_TEXT, black); + if (!surface) + return False; + Bool renderable = surface->w > 0 && surface->h > 0; + SDL_FreeSurface(surface); + return renderable; +} + +static TTF_Font *openRenderableProbeFont(const char *const *paths, + size_t count, + int size, + const char *skipPath) +{ + for (size_t i = 0; i < count; i++) { + if (skipPath && !strcmp(paths[i], skipPath)) + continue; + TTF_Font *font = TTF_OpenFont(paths[i], size); + if (!font) + continue; + if (fontCanRenderText(font)) + return font; + TTF_CloseFont(font); + } + return NULL; +} + +static TTF_Font *openRenderableFallbackFont(const char *name, + int size, + const char *skipPath) +{ + TTF_Font *font = NULL; + if (containsIgnoreCase(name, "times") || + containsIgnoreCase(name, "adobe-times")) { + font = openRenderableProbeFont( + SERIF_PROBE_PATHS, ARRAY_LENGTH(SERIF_PROBE_PATHS), size, skipPath); + if (font) + return font; + } + if (containsIgnoreCase(name, "helvetica") || + containsIgnoreCase(name, "helv") || + containsIgnoreCase(name, "lucida") || + containsIgnoreCase(name, "arial") || + ((strstr(name, "-medium-r-") || strstr(name, "-bold-r-")) && + strstr(name, "-p-"))) { + if (containsIgnoreCase(name, "bold")) { + font = openRenderableProbeFont(SANS_BOLD_PROBE_PATHS, + ARRAY_LENGTH(SANS_BOLD_PROBE_PATHS), + size, skipPath); + if (font) + return font; + } + font = openRenderableProbeFont( + SANS_PROBE_PATHS, ARRAY_LENGTH(SANS_PROBE_PATHS), size, skipPath); + if (font) + return font; + } + font = openRenderableProbeFont(MONOSPACE_PROBE_PATHS, + ARRAY_LENGTH(MONOSPACE_PROBE_PATHS), size, + skipPath); + if (font) + return font; + for (size_t i = 0; i < fontCache->length; i++) { + FontCacheEntry *entry = fontCache->array[i]; + if (skipPath && !strcmp(entry->filePath, skipPath)) + continue; + if (!entry->asciiMetrics) + continue; + font = TTF_OpenFont(entry->filePath, size); + if (!font) + continue; + if (fontCanRenderText(font)) + return font; + TTF_CloseFont(font); + } + return NULL; +} + static FontCacheEntry *findFontCacheEntryByName(const char *name) { /* Exact cache match wins so a real scanned font is never shadowed @@ -686,17 +1079,20 @@ static FontCacheEntry *findFontCacheEntryByName(const char *name) } } if (strchr(name, '*') || strchr(name, '?')) { + Bool aliasRequest = isFontAlias(name); + if (aliasRequest) { + FontCacheEntry *fallback = findAliasedFontForName(name); + if (fallback) + return fallback; + } for (size_t i = 0; i < fontCache->length; i++) { FontCacheEntry *entry = fontCache->array[i]; + if (aliasRequest && !entry->asciiMetrics) + continue; if (matchWildcard(name, entry->XLFName)) { return entry; } } - if (isFontAlias(name)) { - FontCacheEntry *fallback = findAliasedFontForName(name); - if (fallback) - return fallback; - } } if (isFontAlias(name)) { FontCacheEntry *aliased = findAliasedFontForName(name); @@ -731,7 +1127,8 @@ Font XLoadFont(Display *display, _Xconst char *name) handleOutOfMemory(0, display, 0, 0); return None; } - resource->ttf = TTF_OpenFont(fontEntry->filePath, requestedFontSize(name)); + int fontSize = requestedFontSize(name); + resource->ttf = TTF_OpenFont(fontEntry->filePath, fontSize); if (!resource->ttf) { free(resource); FREE_XID(font); @@ -739,9 +1136,18 @@ Font XLoadFont(Display *display, _Xconst char *name) handleError(0, display, None, 0, BadName, 0); return None; } + if (!fontCanRenderText(resource->ttf)) { + TTF_Font *fallback = + openRenderableFallbackFont(name, fontSize, fontEntry->filePath); + if (fallback) { + TTF_CloseFont(resource->ttf); + resource->ttf = fallback; + } + } resource->hasCoreMetrics = coreFontMetricsForName(name, &resource->coreAscent, &resource->coreDescent, &resource->coreWidth); + resource->useFixedBitmap = usesFixedFallbackFont(name); SET_XID_VALUE(font, resource); return font; } @@ -813,6 +1219,101 @@ int XFreeFontPath(char **list) return 1; } +static Bool stringArrayContains(const Array *array, const char *string) +{ + if (!array || !string) + return False; + for (size_t i = 0; i < array->length; i++) { + const char *entry = array->array[i]; + if (entry && !strcmp(entry, string)) + return True; + } + return False; +} + +static char *internAliasFontName(const char *name) +{ + if (!name || !fontAliasNames) + return NULL; + for (size_t i = 0; i < fontAliasNames->length; i++) { + char *cached = fontAliasNames->array[i]; + if (cached && !strcmp(cached, name)) + return cached; + } + char *copy = strdup(name); + if (!copy) + return NULL; + if (!insertArray(fontAliasNames, copy)) { + free(copy); + return NULL; + } + return copy; +} + +static const char *aliasFamilyForPattern(const char *pattern) +{ + if (containsIgnoreCase(pattern, "times")) + return "Times"; + if (containsIgnoreCase(pattern, "helvetica") || + containsIgnoreCase(pattern, "helv")) + return "Helvetica"; + if (containsIgnoreCase(pattern, "courier")) + return "Courier"; + if (containsIgnoreCase(pattern, "fixed") || + containsIgnoreCase(pattern, "jis-fixed")) + return "Fixed"; + if (!strcmp(pattern, "variable")) + return "Helvetica"; + return "Fixed"; +} + +static char aliasSlantForPattern(const char *pattern) +{ + if (containsIgnoreCase(pattern, "-i-") || + containsIgnoreCase(pattern, "-o-")) + return 'i'; + return 'r'; +} + +static char aliasSpacingForPattern(const char *pattern) +{ + if (containsIgnoreCase(pattern, "fixed") || + containsIgnoreCase(pattern, "courier") || !strcmp(pattern, "fixed") || + !strcmp(pattern, "cursor")) + return 'm'; + return 'p'; +} + +static char *concreteAliasNameForPattern(const char *pattern) +{ + if (!pattern) + return NULL; + if (!strchr(pattern, '*') && !strchr(pattern, '?')) + return internAliasFontName(pattern); + const char *family = aliasFamilyForPattern(pattern); + const char *weight = + containsIgnoreCase(pattern, "bold") ? "bold" : "medium"; + char slant = aliasSlantForPattern(pattern); + char spacing = aliasSpacingForPattern(pattern); + int pixelSize = requestedFontSize(pattern); + char name[256]; + snprintf(name, sizeof(name), + "-compat-%s-%s-%c-normal--%d-0-0-0-%c-0-iso10646-1", family, + weight, slant, pixelSize, spacing); + return internAliasFontName(name); +} + +static Bool aliasPatternNeedsSyntheticName(const char *pattern) +{ + return containsIgnoreCase(pattern, "times") || + containsIgnoreCase(pattern, "helvetica") || + containsIgnoreCase(pattern, "helv") || + containsIgnoreCase(pattern, "arial") || + containsIgnoreCase(pattern, "lucida") || + ((strstr(pattern, "-medium-r-") || strstr(pattern, "-bold-r-")) && + strstr(pattern, "-p-")); +} + char **XGetFontPath(Display *display, int *npaths_return) { SET_X_SERVER_REQUEST(display, X_GetFontPath); @@ -879,22 +1380,66 @@ char **XListFonts(Display *display, } } } + Bool aliasPattern = isFontAlias(pattern); + if (aliasPattern && (int) names.length < maxnames) { + FontCacheEntry *aliased = findAliasedFontForName(pattern); + char *aliasName = aliasPatternNeedsSyntheticName(pattern) + ? concreteAliasNameForPattern(pattern) + : (aliased ? aliased->XLFName : NULL); + if (aliased && aliasName && !stringArrayContains(&names, aliasName)) { + insertArray(&names, aliasName); + } + } size_t i; for (i = 0; i < fontCache->length && (int) names.length < maxnames; i++) { - char *name = ((FontCacheEntry *) fontCache->array[i])->XLFName; + FontCacheEntry *entry = fontCache->array[i]; + if (aliasPattern && !entry->asciiMetrics) + continue; + char *name = entry->XLFName; + if (aliasPattern && stringArrayContains(&names, name)) { + continue; + } if (matchWildcard(pattern, name)) { insertArray(&names, name); } } + /* Resolve aliases to a stable cache-owned name. Returning the + * caller's pattern would alias caller-owned memory and could embed + * wildcards in the result list. */ + if (names.length == 0 && isFontAlias(pattern)) { + FontCacheEntry *aliased = findAliasedFontForName(pattern); + char *aliasName = aliasPatternNeedsSyntheticName(pattern) + ? concreteAliasNameForPattern(pattern) + : (aliased ? aliased->XLFName : NULL); + if (aliased && aliasName) + insertArray(&names, aliasName); + } *actual_count_return = (int) names.length; if (names.length == 0) return NULL; - char **list = malloc(sizeof(char *) * names.length); + /* Copy names into caller-owned storage. Returning raw cache or + * alias-intern pointers would dangle after XSetFontPath rebuilds + * the font cache, so each entry is duplicated. The trailing NULL + * lets XFreeFontNames walk the list without needing a separate + * count. */ + char **list = malloc(sizeof(char *) * (names.length + 1)); if (!list) { + freeArray(&names); *actual_count_return = 0; return NULL; } - memcpy(list, names.array, sizeof(char *) * names.length); + for (size_t k = 0; k < names.length; k++) { + list[k] = strdup((const char *) names.array[k]); + if (!list[k]) { + for (size_t f = 0; f < k; f++) + free(list[f]); + free(list); + freeArray(&names); + *actual_count_return = 0; + return NULL; + } + } + list[names.length] = NULL; freeArray(&names); return list; } @@ -902,6 +1447,10 @@ char **XListFonts(Display *display, int XFreeFontNames(char **list) { // https://tronche.com/gui/x/xlib/graphics/font-metrics/XFreeFontNames.html + if (!list) + return 1; + for (char **p = list; *p; p++) + free(*p); free(list); return 1; } @@ -1031,6 +1580,49 @@ static void updateBounds(XFontStruct *fontStruct, XCharStruct *charStruct) } } +static short fallbackPrintableWidth(const XFontStruct *fontStruct) +{ + if (fontStruct->per_char && 'n' >= fontStruct->min_char_or_byte2 && + 'n' <= fontStruct->max_char_or_byte2) { + short nWidth = + fontStruct->per_char['n' - fontStruct->min_char_or_byte2].width; + if (nWidth > 0) + return nWidth; + } + if (fontStruct->max_bounds.width > 0) + return fontStruct->max_bounds.width; + if (fontStruct->min_bounds.width > 0) + return fontStruct->min_bounds.width; + short halfHeight = (short) ((fontStruct->ascent + fontStruct->descent) / 2); + return halfHeight > 0 ? halfHeight : 1; +} + +static void normalizePrintableAsciiWidths(XFontStruct *fontStruct) +{ + short fallback = fallbackPrintableWidth(fontStruct); + if (fontStruct->per_char) { + for (unsigned int ch = ' '; ch <= '~'; ch++) { + if (ch < fontStruct->min_char_or_byte2 || + ch > fontStruct->max_char_or_byte2) { + continue; + } + XCharStruct *charStruct = + &fontStruct->per_char[ch - fontStruct->min_char_or_byte2]; + if (charStruct->width <= 0) { + charStruct->width = fallback; + if (charStruct->rbearing <= charStruct->lbearing) + charStruct->rbearing = fallback; + updateBounds(fontStruct, charStruct); + } + } + } else if (fontStruct->min_bounds.width <= 0) { + fontStruct->min_bounds.width = fallback; + fontStruct->max_bounds.width = fallback; + fontStruct->min_bounds.rbearing = fallback; + fontStruct->max_bounds.rbearing = fallback; + } +} + static Bool fillFontStructFromTTF(Display *display, Font fontId, TTF_Font *font, @@ -1063,6 +1655,16 @@ static Bool fillFontStructFromTTF(Display *display, } if (!foundChar) { + short fallback = + (short) ((fontStruct->ascent + fontStruct->descent) / 2); + if (fallback <= 0) + fallback = 1; + fontStruct->min_bounds.width = fallback; + fontStruct->min_bounds.lbearing = 0; + fontStruct->min_bounds.rbearing = fallback; + fontStruct->min_bounds.ascent = fontStruct->ascent; + fontStruct->min_bounds.descent = fontStruct->descent; + fontStruct->max_bounds = fontStruct->min_bounds; return True; } @@ -1091,6 +1693,7 @@ static Bool fillFontStructFromTTF(Display *display, } updateBounds(fontStruct, &charStruct); } + normalizePrintableAsciiWidths(fontStruct); fontStruct->all_chars_exist = allCharsExist; return True; } @@ -1220,97 +1823,22 @@ XFontStruct *XQueryFont(Display *display, XID fontId) return fontStruct; } -static __inline__ char hexCharToNum(char chr) -{ - return (char) (chr >= 'a' ? 10 + chr - 'a' : chr - '0'); -} - -/* Resolve all X11 control characters. `count` is caller-controlled and - * may end mid-escape; each branch validates that the escape's payload - * fits before reading it, so malformed input gets the literal "\X" - * preserved instead of running off the end of `string`. */ +/* XDrawString and friends take a length-bounded buffer that may or may + * not be NUL-terminated. Downstream SDL_ttf calls expect a C string, so + * we copy `count` bytes into a fresh allocation and NUL-terminate. The + * payload is rendered as-is: real Xlib treats every byte as a glyph + * index into the font, so escape interpretation (\n, \xHH, ...) would + * silently mangle legitimate Latin-1/file-path input. */ char *decodeString(const char *string, int count) { if (!string || count < 0) return NULL; - int counter = 0; char *text = malloc((size_t) count + 1); if (!text) return NULL; - for (int i = 0; i < count; i++) { - if (string[i] != '\\') { - text[counter++] = string[i]; - continue; - } - if (i + 1 >= count) { - /* Trailing backslash; emit literally and stop. */ - text[counter++] = '\\'; - break; - } - char esc = string[i + 1]; - switch (esc) { - case 'x': /* \xHL */ - if (i + 3 < count) { - text[counter++] = (char) ((hexCharToNum(string[i + 2]) << 4) | - hexCharToNum(string[i + 3])); - i += 3; - } else { - text[counter++] = '\\'; - text[counter++] = esc; - i += 1; - } - break; - case 'u': /* \uHHLL packed as 2 bytes */ - if (i + 5 < count) { - text[counter++] = (char) ((hexCharToNum(string[i + 2]) << 4) | - hexCharToNum(string[i + 3])); - text[counter++] = (char) ((hexCharToNum(string[i + 4]) << 4) | - hexCharToNum(string[i + 5])); - i += 5; - } else { - text[counter++] = '\\'; - text[counter++] = esc; - i += 1; - } - break; - case 'n': - text[counter++] = '\n'; - i += 1; - break; - case 'r': - text[counter++] = '\r'; - i += 1; - break; - case 'a': - text[counter++] = '\a'; - i += 1; - break; - case 'b': - text[counter++] = '\b'; - i += 1; - break; - case 't': - text[counter++] = '\t'; - i += 1; - break; - case 'v': - text[counter++] = '\v'; - i += 1; - break; - case 'f': - text[counter++] = '\f'; - i += 1; - break; - default: - LOG("Warn: Got unknown control character in %s: '\\%c'\n", __func__, - esc); - text[counter++] = '\\'; - text[counter++] = esc; - i += 1; - break; - } - } - text[counter] = '\0'; + if (count > 0) + memcpy(text, string, (size_t) count); + text[count] = '\0'; return text; } @@ -1361,6 +1889,12 @@ static char *decodeChar2bString(const XChar2b *string, int count, size_t *length) { + /* Reject negative counts and bound the buffer so the worst-case 3 + * bytes per codepoint plus the NUL cannot wrap size_t on 32-bit. */ + if (!string || count < 0) + return NULL; + if ((size_t) count > (SIZE_MAX - 1) / 3) + return NULL; size_t bytes = 0; for (int i = 0; i < count; i++) { unsigned int codepoint = @@ -1571,6 +2105,102 @@ static TextCacheEntry *textCacheReserveSlot(void) return victim; } +static Bool fixedBitmapCanRenderText(const char *string, size_t length) +{ + /* Length-counted: a 16-bit request with a {0,0} XChar2b decodes to a + * NUL byte mid-buffer that must still render as glyph 0, not end the + * scan. */ + const unsigned char *bytes = (const unsigned char *) string; + for (size_t i = 0; i < length; i++) { + if (bytes[i] >= FIXED_BITMAP_CHAR_COUNT) + return False; + } + return True; +} + +static Bool flushFixedBitmapRects(SDL_Renderer *renderer, + SDL_Rect *rects, + int *count) +{ + if (*count == 0) + return True; + if (SDL_RenderFillRects(renderer, rects, *count) != 0) + return False; + *count = 0; + return True; +} + +static Bool renderFixedBitmapText(Drawable drawable, + SDL_Renderer *renderer, + GC gc, + int x, + int y, + const char *string, + size_t length, + SDL_Rect *drawnBounds) +{ + if (!loadFixedBitmapFont() || !fixedBitmapCanRenderText(string, length)) + return False; + + GraphicContext *gContext = GET_GC(gc); + unsigned long foreground = colorWithOpaqueDefault(gContext->foreground); + applySdlDrawState(renderer, gc, SDL_BLENDMODE_NONE, foreground); + + /* Cap so (int)(len * FIXED_BITMAP_WIDTH) cannot overflow SDL_Rect's + * int width. Pathological inputs would otherwise wrap negative and + * either skip clipping or paint outside the intended damage area. */ + size_t len = length; + if (len > (size_t) (INT_MAX / FIXED_BITMAP_WIDTH)) + len = (size_t) (INT_MAX / FIXED_BITMAP_WIDTH); + SDL_Rect bounds = {x, y - FIXED_BITMAP_ASCENT, + (int) (len * FIXED_BITMAP_WIDTH), FIXED_BITMAP_HEIGHT}; + if (drawnBounds) + *drawnBounds = bounds; + ShapeGuard sg; + shapeGuardBegin(&sg, drawable, renderer, &bounds); + + int clipCount = getGcClipIterationCount(gc); + Bool ok = True; + for (int clip = 0; clip < clipCount && ok; clip++) { + if (!setGcClipForIteration(renderer, gc, clip)) + continue; + + SDL_Rect rects[512]; + int rectCount = 0; + for (size_t i = 0; i < len && ok; i++) { + const unsigned char ch = (unsigned char) string[i]; + int charX = x + (int) i * FIXED_BITMAP_WIDTH; + for (int row = 0; row < FIXED_BITMAP_HEIGHT && ok; row++) { + unsigned char bits = fixedBitmapFont.rows[ch][row]; + /* Pack consecutive set bits on this row into a single + * SDL_Rect with w>1. A glyph with full ink on a row used to + * emit 6 rects; now it emits 1. */ + int col = 0; + while (col < FIXED_BITMAP_WIDTH && ok) { + if (!(bits & (0x80 >> col))) { + col++; + continue; + } + int runStart = col; + do { + col++; + } while (col < FIXED_BITMAP_WIDTH && + (bits & (0x80 >> col))); + rects[rectCount++] = (SDL_Rect) { + charX + runStart, bounds.y + row, col - runStart, 1}; + if (rectCount == (int) ARRAY_LENGTH(rects)) + ok = flushFixedBitmapRects(renderer, rects, &rectCount); + } + } + } + if (ok) + ok = flushFixedBitmapRects(renderer, rects, &rectCount); + } + clearRendererClip(renderer); + shapeGuardEnd(&sg); + return ok; +} + /* Returns True if the cache took ownership of `texture`. False if the * caller is responsible for destroying it (e.g., string too long or * out-of-memory key allocation). */ @@ -1611,18 +2241,21 @@ Bool renderText(Display *display, GC gc, int x, int y, - const char *string) + const char *string, + size_t length, + SDL_Rect *drawnBounds) { - if (!string || string[0] == '\0') { + if (!string || length == 0) { return True; } - LOG("Rendering text: '%s'\n", string); + LOG("Rendering text (length=%zu): '%s'\n", length, string); GraphicContext *gContext = GET_GC(gc); + unsigned long foreground = colorWithOpaqueDefault(gContext->foreground); SDL_Color color = { - GET_RED_FROM_COLOR(gContext->foreground), - GET_GREEN_FROM_COLOR(gContext->foreground), - GET_BLUE_FROM_COLOR(gContext->foreground), - GET_ALPHA_FROM_COLOR(gContext->foreground), + GET_RED_FROM_COLOR(foreground), + GET_GREEN_FROM_COLOR(foreground), + GET_BLUE_FROM_COLOR(foreground), + GET_ALPHA_FROM_COLOR(foreground), }; if (gContext->font == None) { /* Lazily match Xlib's default-GC behavior: a GC without an explicit @@ -1637,7 +2270,19 @@ Bool renderText(Display *display, return False; } } + CompatFont *fontResource = GET_FONT_RESOURCE(gContext->font); + if (fontResource && fontResource->useFixedBitmap && + renderFixedBitmapText(drawable, renderer, gc, x, y, string, length, + drawnBounds)) { + return True; + } + /* TTF_RenderUTF8_Solid stops at the first NUL, so embedded NULs only + * render their prefix here (the fixed-bitmap path above covers Motif's + * default "fixed" font, which is the realistic case). The cache key + * uses strdup, so skip lookup and insert when length != strlen to + * avoid storing or mis-hitting a truncated key. */ + Bool stringHasEmbeddedNul = strlen(string) != length; SDL_Texture *fontTexture = NULL; int textureWidth = 0; int textureHeight = 0; @@ -1647,8 +2292,11 @@ Bool renderText(Display *display, * SDL_RenderCopy loop. Releasing earlier would let a concurrent * invalidate-eviction free the texture out from under us. */ textCacheLock(); - TextCacheEntry *hit = textCacheLookup( - gContext->font, (Uint32) gContext->foreground, renderer, string); + TextCacheEntry *hit = + stringHasEmbeddedNul + ? NULL + : textCacheLookup(gContext->font, (Uint32) foreground, renderer, + string); if (hit) { fontTexture = hit->texture; textureWidth = hit->width; @@ -1656,7 +2304,7 @@ Bool renderText(Display *display, textureAscent = hit->ascent; } else { SDL_Surface *fontSurface = - TTF_RenderUTF8_Solid(GET_FONT(gContext->font), string, color); + TTF_RenderUTF8_Blended(GET_FONT(gContext->font), string, color); if (!fontSurface) { textCacheUnlock(); return False; @@ -1669,10 +2317,12 @@ Bool renderText(Display *display, textCacheUnlock(); return False; } + SDL_SetTextureBlendMode(fontTexture, SDL_BLENDMODE_BLEND); textureAscent = TTF_FontAscent(GET_FONT(gContext->font)); - if (!textCacheInsert(gContext->font, (Uint32) gContext->foreground, - renderer, string, fontTexture, textureWidth, - textureHeight, textureAscent)) { + if (stringHasEmbeddedNul || + !textCacheInsert(gContext->font, (Uint32) foreground, renderer, + string, fontTexture, textureWidth, textureHeight, + textureAscent)) { textureOwned = True; } } @@ -1682,6 +2332,8 @@ Bool renderText(Display *display, destR.h = textureHeight; destR.x = x; destR.y = y - textureAscent; + if (drawnBounds) + *drawnBounds = destR; ShapeGuard sg; shapeGuardBegin(&sg, drawable, renderer, &destR); int clipCount = getGcClipIterationCount(gc); @@ -1699,6 +2351,8 @@ Bool renderText(Display *display, textCacheUnlock(); if (textureOwned) SDL_DestroyTexture(fontTexture); + if (!ok && drawnBounds) + drawnBounds->w = drawnBounds->h = 0; return ok; } @@ -1707,14 +2361,15 @@ static int drawImageString(Display *display, GC gc, int x, int y, - char *text) + char *text, + size_t length) { TYPE_CHECK(drawable, DRAWABLE, display, 0); if (!gc) { handleError(0, display, None, 0, BadGC, 0); return 0; } - if (!text || text[0] == '\0') { + if (!text || length == 0) { return 1; } @@ -1738,16 +2393,33 @@ static int drawImageString(Display *display, return 0; } } - TTF_Font *font = GET_FONT(gContext->font); int width = 0; int height = 0; - if (TTF_SizeUTF8(font, text, &width, &height) != 0) { - XFontStruct *metrics = XQueryFont(display, gContext->font); - width = metrics ? getTextWidth(metrics, text) : 0; - freeFontStruct(metrics); + int ascent = 0; + int descent = 0; + CompatFont *fontResource = GET_FONT_RESOURCE(gContext->font); + /* The clear box must match the renderer that will actually run. Use + * the bitmap 11/2 box only when renderFixedBitmapText will fire; + * otherwise the TTF fallback box is needed to cover its glyphs. */ + if (fontResource && fontResource->useFixedBitmap && + fixedBitmapCanRenderText(text, length) && loadFixedBitmapFont()) { + size_t textLen = length; + if (textLen > (size_t) (INT_MAX / FIXED_BITMAP_WIDTH)) + textLen = (size_t) (INT_MAX / FIXED_BITMAP_WIDTH); + width = (int) (textLen * FIXED_BITMAP_WIDTH); + ascent = FIXED_BITMAP_ASCENT; + descent = FIXED_BITMAP_DESCENT; + } else { + TTF_Font *font = GET_FONT(gContext->font); + if (TTF_SizeUTF8(font, text, &width, &height) != 0) { + XFontStruct *metrics = XQueryFont(display, gContext->font); + width = metrics ? getTextWidth(metrics, text) : 0; + freeFontStruct(metrics); + } + ascent = TTF_FontAscent(font); + descent = abs(TTF_FontDescent(font)); } - SDL_Rect background = {x, y - TTF_FontAscent(font), width, - TTF_FontAscent(font) + abs(TTF_FontDescent(font))}; + SDL_Rect background = {x, y - ascent, width, ascent + descent}; applySdlDrawState(renderer, gc, SDL_BLENDMODE_NONE, gContext->background); ShapeGuard sg; shapeGuardBegin(&sg, drawable, renderer, &background); @@ -1766,10 +2438,13 @@ static int drawImageString(Display *display, } clearRendererClip(renderer); shapeGuardEnd(&sg); + SDL_Rect damage = {0, 0, 0, 0}; int result = - renderText(display, drawable, renderer, gc, x, y, text) ? 1 : 0; + renderText(display, drawable, renderer, gc, x, y, text, length, &damage) + ? 1 + : 0; if (result) - presentDrawableIfVisible(drawable); + finishTextDamage(display, drawable, &damage); return result; } @@ -1783,7 +2458,7 @@ int XDrawImageString(Display *display, { // https://tronche.com/gui/x/xlib/graphics/drawing-text/XDrawImageString.html SET_X_SERVER_REQUEST(display, X_ImageText8); - if (length == 0 || string[0] == '\0') { + if (length <= 0 || !string) { return 1; } char *text = decodeString(string, length); @@ -1791,7 +2466,8 @@ int XDrawImageString(Display *display, handleError(0, display, drawable, 0, BadAlloc, 0); return 0; } - int result = drawImageString(display, drawable, gc, x, y, text); + int result = + drawImageString(display, drawable, gc, x, y, text, (size_t) length); free(text); return result; } @@ -1806,7 +2482,10 @@ int XDrawImageString16(Display *display, { // https://tronche.com/gui/x/xlib/graphics/drawing-text/XDrawImageString16.html SET_X_SERVER_REQUEST(display, X_ImageText16); - if (length == 0 || ((Uint16 *) string)[0] == 0) { + /* Length-counted Xlib API: glyph U+0000 is still part of the + * request and must be drawn. Only short-circuit on a missing buffer + * or zero length, never on a leading NUL glyph. */ + if (length <= 0 || !string) { return 1; } size_t size; @@ -1815,7 +2494,7 @@ int XDrawImageString16(Display *display, handleError(0, display, drawable, 0, BadAlloc, 0); return 0; } - int result = drawImageString(display, drawable, gc, x, y, text); + int result = drawImageString(display, drawable, gc, x, y, text, size); free(text); return result; } @@ -1836,7 +2515,8 @@ int XDrawString16(Display *display, handleError(0, display, None, 0, BadGC, 0); return 0; } - if (length == 0 || ((Uint16 *) string)[0] == 0) { + /* See XDrawImageString16: a leading U+0000 glyph is real data. */ + if (length <= 0 || !string) { return 1; } SDL_Renderer *renderer; @@ -1855,15 +2535,16 @@ int XDrawString16(Display *display, return 0; } int res = 1; - if (!renderText(display, drawable, renderer, gc, x, y, text)) { + SDL_Rect damage = {0, 0, 0, 0}; + if (!renderText(display, drawable, renderer, gc, x, y, text, size, + &damage)) { LOG("Rendering the text failed in %s: %s\n", __func__, SDL_GetError()); handleError(0, display, drawable, 0, BadMatch, 0); - free(text); res = 0; } free(text); if (res) - presentDrawableIfVisible(drawable); + finishTextDamage(display, drawable, &damage); return res; } @@ -1883,7 +2564,7 @@ int XDrawString(Display *display, handleError(0, display, None, 0, BadGC, 0); return 0; } - if (length == 0 || string[0] == 0) { + if (length <= 0 || !string) { return 1; } SDL_Renderer *renderer; @@ -1901,13 +2582,15 @@ int XDrawString(Display *display, return 0; } int res = 1; - if (!renderText(display, drawable, renderer, gc, x, y, text)) { + SDL_Rect damage = {0, 0, 0, 0}; + if (!renderText(display, drawable, renderer, gc, x, y, text, + (size_t) length, &damage)) { LOG("Rendering the text failed in %s: %s\n", __func__, SDL_GetError()); handleError(0, display, drawable, 0, BadMatch, 0); res = 0; } free(text); if (res) - presentDrawableIfVisible(drawable); + finishTextDamage(display, drawable, &damage); return res; } diff --git a/src/gc.h b/src/gc.h index 875a36a..891bb09 100644 --- a/src/gc.h +++ b/src/gc.h @@ -44,7 +44,26 @@ typedef struct _GraphicContext { #define GC_BUMP_GENERATION(gc) ((gc)->generation++) -#define GET_GC(gc) GET_GC_FROM_XID(((struct _XGC *) (gc))->gid) #define GET_GC_FROM_XID(id) ((GraphicContext *) GET_XID_VALUE(id)) +/* Debug-only NULL guard. Production keeps the bare deref so well-formed + * callers pay nothing; misbehaving callers (or future regressions like + * the round-6 XCopyArea NULL gc finding) abort with file:line. */ +#ifdef DEBUG_LIBX11_COMPAT +#include +#include +#define GET_GC(gc) \ + (__extension__({ \ + GC _gg_gc = (gc); \ + if (!_gg_gc) { \ + fprintf(stderr, "%s:%d: GET_GC(NULL) in debug build\n", __FILE__, \ + __LINE__); \ + abort(); \ + } \ + GET_GC_FROM_XID(((struct _XGC *) _gg_gc)->gid); \ + })) +#else +#define GET_GC(gc) GET_GC_FROM_XID(((struct _XGC *) (gc))->gid) +#endif + #endif /* GC_H */ diff --git a/src/image.c b/src/image.c index 7d1d51f..77fc8bc 100644 --- a/src/image.c +++ b/src/image.c @@ -24,11 +24,50 @@ static struct { int textureHeight; } putImageScratch; +/* XPutImage from multiple threads would otherwise race on the scratch + * pixel buffer's realloc and the staging texture's create/destroy. The + * mutex is created lazily on first use and kept for the lifetime of + * the process: tearing it down in freeImageStorage created a TOCTOU + * window where ensurePutImageScratchLock could return a pointer that + * freeImageStorage destroyed before the caller called SDL_LockMutex + * on it. The memory cost is one SDL_mutex per process. */ +static SDL_mutex *putImageScratchLock = NULL; +static SDL_SpinLock putImageScratchLockInitLock = 0; +static SDL_mutex *ensurePutImageScratchLock(void) +{ + SDL_AtomicLock(&putImageScratchLockInitLock); + if (!putImageScratchLock) + putImageScratchLock = SDL_CreateMutex(); + SDL_mutex *lock = putImageScratchLock; + SDL_AtomicUnlock(&putImageScratchLockInitLock); + return lock; +} +/* Return the acquired mutex so the caller pairs lock/unlock against + * the exact pointer it locked. Reading putImageScratchLock unlocked at + * unlock time would race against another thread initializing it (or, + * if SDL_CreateMutex returned NULL on the first thread and succeeded + * on the second, would try to unlock a mutex this thread never + * locked). NULL return means "no lock was acquired" — pass NULL to + * unlockPutImageScratch and the unlock is a no-op. */ +static SDL_mutex *lockPutImageScratch(void) +{ + SDL_mutex *lock = ensurePutImageScratchLock(); + if (lock) + SDL_LockMutex(lock); + return lock; +} +static void unlockPutImageScratch(SDL_mutex *lock) +{ + if (lock) + SDL_UnlockMutex(lock); +} + static Uint32 *ensurePutImageScratchBuffer(size_t pixelsNeeded) { /* Refuse anything that would overflow size_t when scaled to bytes. * Caller is also expected to validate width/height before this, but - * a second line of defense is cheap. */ + * a second line of defense is cheap. Caller must hold + * putImageScratchLock around the buffer use. */ if (pixelsNeeded > SIZE_MAX / sizeof(Uint32)) return NULL; if (pixelsNeeded > putImageScratch.pixelCapacity) { @@ -68,6 +107,10 @@ static SDL_Texture *ensurePutImageStagingTexture(SDL_Renderer *renderer, void freeImageStorage(void) { + /* Release scratch payload but leave putImageScratchLock alive; + * destroying it here is racy with concurrent callers holding a + * pointer returned by ensurePutImageScratchLock(). */ + SDL_mutex *lock = lockPutImageScratch(); if (putImageScratch.texture) { SDL_DestroyTexture(putImageScratch.texture); putImageScratch.texture = NULL; @@ -78,22 +121,24 @@ void freeImageStorage(void) free(putImageScratch.pixels); putImageScratch.pixels = NULL; putImageScratch.pixelCapacity = 0; + unlockPutImageScratch(lock); } -/* X color encoding to RGBA8888 packed pixel. With colors.h shifts - * R=16, G=8, B=0, A=24, the per-channel "extract and re-pack" sequence - * collapses to a single left-rotate by 8 bits. Compilers lower this to - * one ROL on x86/ARM. Macro form so the per-pixel hot loop stays - * inlined even at -O0. - * - * Evaluates `color` twice. Pass a pure expression (a named local, an - * indexed array read, or a memcpy result) only -- never a side- - * effecting one like *src++. */ -#define X_COLOR_TO_RGBA8888(color) \ - (((Uint32) (color) << 8) | ((Uint32) (color) >> 24)) +/* Pack an X11 pixel into SDL2's RGBA8888 layout. Routes through + * colorWithOpaqueDefault so core-X11 pixels (alpha byte == 0) render + * opaque instead of disappearing into SDL2's alpha-aware blend. */ +static inline Uint32 xColorToRgba8888(unsigned long color) +{ + color = colorWithOpaqueDefault(color); + return ((Uint32) GET_RED_FROM_COLOR(color) << 24) | + ((Uint32) GET_GREEN_FROM_COLOR(color) << 16) | + ((Uint32) GET_BLUE_FROM_COLOR(color) << 8) | + (Uint32) GET_ALPHA_FROM_COLOR(color); +} void invalidatePutImageStagingTexture(SDL_Renderer *renderer) { + SDL_mutex *lock = lockPutImageScratch(); if (putImageScratch.texture && putImageScratch.textureRenderer == renderer) { SDL_DestroyTexture(putImageScratch.texture); @@ -102,11 +147,15 @@ void invalidatePutImageStagingTexture(SDL_Renderer *renderer) putImageScratch.textureWidth = 0; putImageScratch.textureHeight = 0; } + unlockPutImageScratch(lock); } SDL_Renderer *getPutImageStagingTextureRenderer(void) { - return putImageScratch.textureRenderer; + SDL_mutex *lock = lockPutImageScratch(); + SDL_Renderer *r = putImageScratch.textureRenderer; + unlockPutImageScratch(lock); + return r; } #ifdef XPutPixel @@ -201,7 +250,6 @@ XImage *XCreateImage(Display *display, handleOutOfMemory(0, display, 0, 0); return NULL; } - LOG("%s: w = %d, h = %d\n", __func__, (int) width, (int) height); image->width = width; image->height = height; image->xoffset = offset; @@ -512,8 +560,6 @@ int XPutImage(Display *display, handleError(0, display, drawable, 0, BadMatch, 0); return -1; } - LOG("%s: Drawing %p on %lu\n", __func__, image, drawable); - SDL_Renderer *renderer = NULL; GET_RENDERER(drawable, renderer); if (!renderer) { @@ -522,13 +568,31 @@ int XPutImage(Display *display, return -1; } + if (!image) { + handleError(0, display, drawable, 0, BadMatch, 0); + return -1; + } + + /* Reject geometries that would either overflow the scratch-buffer + * size or exceed what SDL_Rect (signed int) and SDL_UpdateTexture's + * pitch parameter (signed int) can address downstream. The width + * cap is INT_MAX / sizeof(Uint32) so width * 4 fits in the pitch + * argument. */ + if (width > (unsigned int) (INT_MAX / sizeof(Uint32)) || + height > (unsigned int) INT_MAX || + (height != 0 && width > SIZE_MAX / sizeof(Uint32) / (size_t) height)) { + handleError(0, display, drawable, 0, BadValue, 0); + return -1; + } + /* Reject requests whose source rectangle does not lie wholly inside * the XImage. The old XGetPixel fallback silently returned 0 for * out-of-bounds pixels; the new direct fast paths read raw bytes - * from image->data and would segfault. */ - if (!image || src_x < 0 || src_y < 0 || image->width < 0 || - image->height < 0 || (long) src_x + (long) width > image->width || - (long) src_y + (long) height > image->height) { + * from image->data and would segfault. The width/height bound above + * guarantees the size_t addition cannot wrap. */ + if (src_x < 0 || src_y < 0 || image->width < 0 || image->height < 0 || + (size_t) src_x + (size_t) width > (size_t) image->width || + (size_t) src_y + (size_t) height > (size_t) image->height) { handleError(0, display, drawable, 0, BadMatch, 0); return -1; } @@ -537,16 +601,44 @@ int XPutImage(Display *display, return -1; } - /* Reject geometries that would either overflow the scratch-buffer - * size or exceed what SDL_Rect (signed int) can address downstream. */ - if (width > (unsigned int) INT_MAX || height > (unsigned int) INT_MAX || - (height != 0 && width > SIZE_MAX / sizeof(Uint32) / (size_t) height)) { - handleError(0, display, drawable, 0, BadValue, 0); - return -1; + /* Stride sanity for every format that dereferences image->data. + * A negative or undersized bytes_per_line lets either the fast raw- + * byte paths or XGetPixel (which still uses bytes_per_line for the + * row offset) step past the caller's buffer. Caps: + * ZPixmap : ceil(maxX * bits_per_pixel / 8) bytes per row + * XYBitmap: ceil(maxX / 8) bytes per row + * XYPixmap: ceil(maxX / 8) bytes per plane row */ + if (image->data && height > 0) { + if (image->bytes_per_line < 0) { + handleError(0, display, drawable, 0, BadMatch, 0); + return -1; + } + size_t maxX = (size_t) src_x + (size_t) width; + size_t minStride = 0; + if (image->format == ZPixmap) { + int bpp = image->bits_per_pixel; + if (bpp <= 0 || bpp > 32 || maxX > (SIZE_MAX - 7) / (size_t) bpp) { + handleError(0, display, drawable, 0, BadMatch, 0); + return -1; + } + minStride = (maxX * (size_t) bpp + 7) / 8; + } else if (image->format == XYBitmap || image->format == XYPixmap) { + minStride = (maxX + 7) / 8; + } + if (minStride > 0 && (size_t) image->bytes_per_line < minStride) { + handleError(0, display, drawable, 0, BadMatch, 0); + return -1; + } } + + /* Hold the scratch lock across pixel build + texture upload + + * RenderCopy so a concurrent XPutImage cannot realloc the pixel + * buffer or destroy the staging texture mid-flight. */ + SDL_mutex *scratchLock = lockPutImageScratch(); Uint32 *data = ensurePutImageScratchBuffer((size_t) width * (size_t) height); if (!data) { + unlockPutImageScratch(scratchLock); handleOutOfMemory(0, display, 0, 0); return -1; } @@ -555,11 +647,7 @@ int XPutImage(Display *display, image->format == XYBitmap ? GET_GC(gc) : NULL; if (!image->data) { if (image->format == XYBitmap && graphicContext) { - Uint32 background = - GET_RED_FROM_COLOR(graphicContext->background) << 24 | - GET_GREEN_FROM_COLOR(graphicContext->background) << 16 | - GET_BLUE_FROM_COLOR(graphicContext->background) << 8 | - GET_ALPHA_FROM_COLOR(graphicContext->background); + Uint32 background = xColorToRgba8888(graphicContext->background); for (unsigned int y = 0; y < height; y++) { Uint32 *dst = data + y * width; for (unsigned int x = 0; x < width; x++) @@ -576,34 +664,27 @@ int XPutImage(Display *display, Bool aligned = (image->bytes_per_line % (int) sizeof(Uint32)) == 0 && (((uintptr_t) image->data) % sizeof(Uint32)) == 0; for (unsigned int y = 0; y < height; y++) { - const char *srcRow = image->data + - image->bytes_per_line * (src_y + (int) y) + - src_x * (int) sizeof(Uint32); + const char *srcRow = + image->data + + (size_t) image->bytes_per_line * ((size_t) src_y + y) + + (size_t) src_x * sizeof(Uint32); Uint32 *dst = data + y * width; if (aligned) { const Uint32 *src = (const Uint32 *) srcRow; for (unsigned int x = 0; x < width; x++) { - dst[x] = X_COLOR_TO_RGBA8888(src[x]); + dst[x] = xColorToRgba8888(src[x]); } } else { for (unsigned int x = 0; x < width; x++) { Uint32 color; memcpy(&color, srcRow + x * sizeof(Uint32), sizeof(Uint32)); - dst[x] = X_COLOR_TO_RGBA8888(color); + dst[x] = xColorToRgba8888(color); } } } } else if (image->format == XYBitmap) { - Uint32 foreground = - GET_RED_FROM_COLOR(graphicContext->foreground) << 24 | - GET_GREEN_FROM_COLOR(graphicContext->foreground) << 16 | - GET_BLUE_FROM_COLOR(graphicContext->foreground) << 8 | - GET_ALPHA_FROM_COLOR(graphicContext->foreground); - Uint32 background = - GET_RED_FROM_COLOR(graphicContext->background) << 24 | - GET_GREEN_FROM_COLOR(graphicContext->background) << 16 | - GET_BLUE_FROM_COLOR(graphicContext->background) << 8 | - GET_ALPHA_FROM_COLOR(graphicContext->background); + Uint32 foreground = xColorToRgba8888(graphicContext->foreground); + Uint32 background = xColorToRgba8888(graphicContext->background); Bool msbFirst = image->bitmap_bit_order == MSBFirst; /* The 256-entry LUT below costs ~2us to build, only worth it * when the image is large enough to amortize over many bytes. @@ -619,7 +700,7 @@ int XPutImage(Display *display, for (unsigned int y = 0; y < height; y++) { const unsigned char *srcRow = (const unsigned char *) image->data + - image->bytes_per_line * (src_y + (int) y); + (size_t) image->bytes_per_line * ((size_t) src_y + y); Uint32 *dst = data + y * width; unsigned int x = 0; unsigned int srcBitX = (unsigned int) src_x; @@ -650,8 +731,8 @@ int XPutImage(Display *display, for (unsigned int x = 0; x < width; x++) { const char *byte = image->data + - image->bytes_per_line * (src_y + (int) y) + - (src_x + (int) x) / 8; + (size_t) image->bytes_per_line * ((size_t) src_y + y) + + ((size_t) src_x + x) / 8; dst[x] = (*byte & bitMaskForImage(image, src_x + (int) x)) ? foreground : background; @@ -663,7 +744,7 @@ int XPutImage(Display *display, for (unsigned int x = 0; x < width; x++) { unsigned long color = XGetPixel(image, src_x + (int) x, src_y + (int) y); - data[y * width + x] = X_COLOR_TO_RGBA8888((Uint32) color); + data[y * width + x] = xColorToRgba8888(color); } } } else { @@ -674,7 +755,7 @@ int XPutImage(Display *display, color = color ? graphicContext->foreground : graphicContext->background; } - data[y * width + x] = X_COLOR_TO_RGBA8888((Uint32) color); + data[y * width + x] = xColorToRgba8888(color); } } } @@ -682,10 +763,13 @@ int XPutImage(Display *display, SDL_Texture *texture = ensurePutImageStagingTexture(renderer, (int) width, (int) height); if (!texture) { + unlockPutImageScratch(scratchLock); LOG("SDL_CreateTexture failed: %s\n", SDL_GetError()); return -1; } - if (SDL_UpdateTexture(texture, NULL, data, width * sizeof(Uint32)) < 0) { + if (SDL_UpdateTexture(texture, NULL, data, (int) (width * sizeof(Uint32))) < + 0) { + unlockPutImageScratch(scratchLock); LOG("SDL_UpdateTexture failed in %s: %s\n", __func__, SDL_GetError()); return -1; } @@ -699,6 +783,7 @@ int XPutImage(Display *display, if (SDL_RenderCopy(renderer, texture, NULL, &dst) < 0) { clearRendererClip(renderer); shapeGuardEnd(&sg); + unlockPutImageScratch(scratchLock); LOG("SDL_RenderCopy failed: %s\n", SDL_GetError()); return -1; } @@ -709,6 +794,7 @@ int XPutImage(Display *display, * recomposes from a fresh baseline rather than flashing stale * output. */ Bool shapeOk = shapeGuardEnd(&sg); + unlockPutImageScratch(scratchLock); if (shapeOk) presentDrawableIfVisible(drawable); return 1; diff --git a/src/input.c b/src/input.c index ae74149..b5a9a91 100644 --- a/src/input.c +++ b/src/input.c @@ -7,9 +7,15 @@ #include "keysymlist.h" #include "errors.h" #include "display.h" +#include "events.h" +#include "window-internal.h" Window keyboardFocus = None; int revertTo = RevertToParent; +/* Distinguishes the two non-window focus targets (None vs PointerRoot) + * for postFocusChange's root-detail code. keyboardFocus itself stays + * collapsed to None for both, so event routing stays binary. */ +static FocusKind keyboardFocusKind = FocusKindNone; static const struct { KeySym keysym; @@ -69,6 +75,39 @@ void setKeyboardFocus(Window window) keyboardFocus = window; } +void revertKeyboardFocusForDestroyedWindow(Display *display, Window window) +{ + /* Xlib spec: when the focus window is destroyed, focus reverts per + * revert_to and the new revert_to becomes RevertToNone (so a + * cascading destroy of the parent does not keep walking up). Route + * through XSetInputFocus so postFocusChange emits the proper + * FocusOut/FocusIn sequence and keyboardFocusKind stays in sync. */ + if (window == None || window != keyboardFocus) + return; + switch (revertTo) { + case RevertToPointerRoot: + XSetInputFocus(display, (Window) PointerRoot, RevertToNone, + CurrentTime); + return; + case RevertToParent: { + /* Xlib spec: "the focus reverts to its parent (or the closest + * viewable ancestor)"; fall back to None if none exists. */ + Window target = GET_PARENT(window); + while (target != None && IS_TYPE(target, WINDOW) && + !isWindowEffectivelyViewable(target)) + target = GET_PARENT(target); + Bool viewable = target != None && IS_TYPE(target, WINDOW); + XSetInputFocus(display, viewable ? target : None, RevertToNone, + CurrentTime); + return; + } + case RevertToNone: + default: + XSetInputFocus(display, None, RevertToNone, CurrentTime); + return; + } +} + int XSelectInput(Display *display, Window window, long event_mask) { // https://tronche.com/gui/x/xlib/event-handling/XSelectInput.html @@ -189,6 +228,8 @@ KeySym XStringToKeysym(_Xconst char *string) if (strcmp(osfKeysyms[i].name, string) == 0) return osfKeysyms[i].keysym; } + if (!strcmp(string, "KDelete")) + return XK_Delete; for (size_t i = 0; i < KEY_SYM_LIST_LENGTH; i++) { if (strcmp(KEY_SYM_LIST[i].name, string) == 0) { return KEY_SYM_LIST[i].keySym; @@ -357,9 +398,17 @@ int XGetInputFocus(Display *display, { // https://tronche.com/gui/x/xlib/input/XGetInputFocus.html SET_X_SERVER_REQUEST(display, X_GetInputFocus); - *focus_return = getKeyboardFocus(); - if (*focus_return == None) + /* Xlib spec: report the actual current focus target. The previous + * implementation collapsed None to PointerRoot in the return value, + * which was non-conformant and indistinguishable from a real + * PointerRoot focus once XSetInputFocus learned to track the two + * cases separately. */ + if (keyboardFocusKind == FocusKindPointerRoot) *focus_return = (Window) PointerRoot; + else if (keyboardFocusKind == FocusKindNone) + *focus_return = None; + else + *focus_return = getKeyboardFocus(); *revert_to_return = revertTo; return 1; } @@ -370,15 +419,24 @@ int XSetInputFocus(Display *display, Window focus, int revert_to, Time time) SET_X_SERVER_REQUEST(display, X_SetInputFocus); (void) time; revertTo = revert_to; - /* PointerRoot and None are valid Xlib targets that the X server - * interprets as "follow pointer" and "no focus." Both map to our - * keyboardFocus = None state since the SDL backend doesn't track a - * separate pointer-following focus. */ - if (focus == PointerRoot || focus == None) { - setKeyboardFocus(None); - } else { - setKeyboardFocus(focus); - } + Window oldFocus = getKeyboardFocus(); + FocusKind oldKind = keyboardFocusKind; + FocusKind newKind; + if (focus == PointerRoot) + newKind = FocusKindPointerRoot; + else if (focus == None) + newKind = FocusKindNone; + else + newKind = FocusKindWindow; + /* Storage stays collapsed: PointerRoot and None both park keyboard + * focus at None for event routing; the kind tracker preserves the + * distinction so postFocusChange emits the correct root detail. */ + Window newFocus = newKind == FocusKindWindow ? focus : None; + if (oldKind == newKind && oldFocus == newFocus) + return 1; + setKeyboardFocus(newFocus); + keyboardFocusKind = newKind; + postFocusChange(display, oldKind, oldFocus, newKind, newFocus); return 1; } @@ -434,12 +492,11 @@ Bool getKeyboardGrabOwnerEvents(void) /* Passive key grabs registered via XGrabKey. Motif menus install one * grab per accelerator (Alt-F for File, Alt-E for Edit, etc.) on the * top-level shell at widget realize time; without tracking the grabs - * here every key event landed at the focus window, accelerators - * silently failed, and DEBUG builds spammed one warning per grab call. + * here every key event landed at the focus window, accelerators silently + * failed, and DEBUG builds spammed one warning per grab call. * - * One global list across Displays mirrors what xlibe does. Motif demos - * are single-display; if multi-Display threading becomes a goal the - * list moves under a Display pointer field. */ + * Motif demos are single-display; if multi-Display threading becomes a + * goal the list moves under a Display pointer field. */ typedef struct KeyGrab { int key; /* X keycode, or AnyKey for all keys */ unsigned int modifiers; /* modifier mask, or AnyModifier */ diff --git a/src/input.h b/src/input.h index de9dce0..e87416e 100644 --- a/src/input.h +++ b/src/input.h @@ -6,8 +6,24 @@ Window getKeyboardFocus(); void setKeyboardFocus(Window window); + +/* If `window` is the current keyboard focus, re-target focus per the + * stored revert_to value (RevertToParent / RevertToPointerRoot / + * RevertToNone) and emit the focus-change events that XSetInputFocus + * would have fired. Call from destroyWindow before the window's + * resources are freed so the event payloads see live state. */ +void revertKeyboardFocusForDestroyedWindow(Display *display, Window window); Window getGrabbedPointerWindow(void); Bool getPointerGrabOwnerEvents(void); +unsigned int getPointerGrabEventMask(void); +Bool activatePassiveButtonGrab(Display *display, + Window root, + int root_x, + int root_y, + unsigned int button, + unsigned int state); +Bool pointerGrabIsPassive(void); +void releasePassivePointerGrab(Display *display); /* Active keyboard grab installed via XGrabKeyboard. Returns the * grab_window or None if no grab is active. While a grab is active the diff --git a/src/missing.c b/src/missing.c index 9b83d70..9b88994 100644 --- a/src/missing.c +++ b/src/missing.c @@ -8,6 +8,7 @@ #include "X11/Xlocale.h" #include #include +#include #include #include #include @@ -18,10 +19,38 @@ #include "errors.h" #include "events.h" #include "font.h" +#include "input.h" #include "input-method.h" #include "window.h" #include "atoms.h" +int _Xdebug; + +/* Saturating 64-bit multiply-add for geometry math. Real Xlib only ever + * deals with display-scale pixels, but XParseGeometry happily accepts + * UINT_MAX units which, multiplied by a UINT_MAX font cell, overshoots + * int64_t. Saturate instead of wrapping so the caller's window placement + * never lands at a nonsensical negative coordinate. */ +static int64_t satMulAdd(int64_t a, int64_t b, int64_t c) +{ + int64_t prod; + if (__builtin_mul_overflow(a, b, &prod)) + prod = ((a > 0) == (b > 0)) ? INT64_MAX : INT64_MIN; + int64_t sum; + if (__builtin_add_overflow(prod, c, &sum)) + return prod > 0 ? INT64_MAX : INT64_MIN; + return sum; +} + +static int clampInt64ToInt(int64_t v) +{ + if (v > INT_MAX) + return INT_MAX; + if (v < INT_MIN) + return INT_MIN; + return (int) v; +} + static void fillTextExtents(XFontStruct *fs, int width, int *dir, @@ -1306,12 +1335,23 @@ static void fillTextExtents(XFontStruct *fs, if (font_descent) *font_descent = fs ? fs->descent : 0; if (overall) { + /* Real Xlib XCharStruct uses short for bearing/width/ascent; + * clamp instead of letting a wide string or huge font silently + * wrap negative. */ + int fsAscent = fs ? fs->ascent : 0; + int fsDescent = fs ? fs->descent : 0; memset(overall, 0, sizeof(*overall)); overall->lbearing = 0; - overall->rbearing = (short) width; - overall->width = (short) width; - overall->ascent = fs ? (short) fs->ascent : 0; - overall->descent = fs ? (short) fs->descent : 0; + overall->rbearing = (short) (width > SHRT_MAX ? SHRT_MAX + : width < SHRT_MIN ? SHRT_MIN + : width); + overall->width = overall->rbearing; + overall->ascent = (short) (fsAscent > SHRT_MAX ? SHRT_MAX + : fsAscent < SHRT_MIN ? SHRT_MIN + : fsAscent); + overall->descent = (short) (fsDescent > SHRT_MAX ? SHRT_MAX + : fsDescent < SHRT_MIN ? SHRT_MIN + : fsDescent); } } @@ -1433,30 +1473,6 @@ int XQueryTextExtents16(register Display *dpy, } -int XGrabButton(register Display *dpy, - unsigned int button, - /* CARD8 */ unsigned int modifiers, - /* CARD16 */ Window grab_window, - Bool owner_events, - unsigned int event_mask, - /* CARD16 */ int pointer_mode, - int keyboard_mode, - Window confine_to, - Cursor curs) -{ - WARN_UNIMPLEMENTED; - return 0; -} - -int XUngrabButton(register Display *dpy, - unsigned int button, - /* CARD8 */ unsigned int modifiers, - /* CARD16 */ Window grab_window) -{ - WARN_UNIMPLEMENTED; - return 0; -} - int XQueryKeymap(register Display *dpy, char keys[32]) { WARN_UNIMPLEMENTED; @@ -1510,8 +1526,11 @@ int XAllowEvents(register Display *dpy, int mode, Time time) extern Bool keyboardFrozen; switch (mode) { case AsyncPointer: + mouseFrozen = False; + break; case ReplayPointer: mouseFrozen = False; + releasePassivePointerGrab(dpy); break; case AsyncKeyboard: case ReplayKeyboard: @@ -1522,8 +1541,14 @@ int XAllowEvents(register Display *dpy, int mode, Time time) keyboardFrozen = False; break; case SyncPointer: + mouseFrozen = False; + break; case SyncKeyboard: + keyboardFrozen = False; + break; case SyncBoth: + mouseFrozen = False; + keyboardFrozen = False; break; default: return 0; @@ -1780,8 +1805,15 @@ int XmbTextEscapement(XFontSet font_set, _Xconst char *text, int text_len) if (!text || text_len <= 0) return 0; CompatFontSet *set = GET_FONT_SET(font_set); - int w = - set && set->font ? XTextWidth(set->font, text, text_len) : text_len * 8; + int w; + if (set && set->font) { + w = XTextWidth(set->font, text, text_len); + } else { + /* 8 px-per-byte fallback runs through int64 so a long text_len + * cannot wrap the returned int. */ + int64_t fallback = (int64_t) text_len * 8; + w = clampInt64ToInt(fallback); + } LOG("XmbTextEscapement: text_len=%d (preview='%.20s') -> %d\n", text_len, text, w); return w; @@ -1792,8 +1824,9 @@ int Xutf8TextEscapement(XFontSet font_set, _Xconst char *text, int text_len) if (!text || text_len <= 0) return 0; CompatFontSet *set = GET_FONT_SET(font_set); - return set && set->font ? XTextWidth(set->font, text, text_len) - : text_len * 8; + if (set && set->font) + return XTextWidth(set->font, text, text_len); + return clampInt64ToInt((int64_t) text_len * 8); } XIM XIMOfIC(XIC ic) @@ -1949,14 +1982,17 @@ int XWMGeometry( heightInc = hints->height_inc; } - int widthUnits = (umask & WidthValue) - ? (int) uwidth - : ((dmask & WidthValue) ? (int) dwidth : 1); - int heightUnits = (umask & HeightValue) - ? (int) uheight - : ((dmask & HeightValue) ? (int) dheight : 1); - int width = widthUnits * widthInc + baseWidth; - int height = heightUnits * heightInc + baseHeight; + /* All pixel math runs in int64_t and routes through satMulAdd so a + * UINT_MAX geometry-units value times UINT_MAX width_inc cannot wrap + * before we clamp into the returned int. */ + int64_t widthUnits = (umask & WidthValue) + ? (int64_t) uwidth + : ((dmask & WidthValue) ? (int64_t) dwidth : 1); + int64_t heightUnits = (umask & HeightValue) + ? (int64_t) uheight + : ((dmask & HeightValue) ? (int64_t) dheight : 1); + int64_t width = satMulAdd(widthUnits, widthInc, baseWidth); + int64_t height = satMulAdd(heightUnits, heightInc, baseHeight); if (width < minWidth) width = minWidth; if (height < minHeight) @@ -1967,29 +2003,30 @@ int XWMGeometry( if (height > hints->max_height) height = hints->max_height; } + int64_t border = 2 * (int64_t) bwidth; - int x = 0; + int64_t x = 0; if (umask & XValue) { x = (umask & XNegative) - ? DisplayWidth(dpy, screen) + ux - width - 2 * (int) bwidth + ? (int64_t) DisplayWidth(dpy, screen) + ux - width - border : ux; } else if (dmask & XValue) { if (dmask & XNegative) { - x = DisplayWidth(dpy, screen) + dx - width - 2 * (int) bwidth; + x = (int64_t) DisplayWidth(dpy, screen) + dx - width - border; rmask |= XNegative; } else { x = dx; } } - int y = 0; + int64_t y = 0; if (umask & YValue) { y = (umask & YNegative) - ? DisplayHeight(dpy, screen) + uy - height - 2 * (int) bwidth + ? (int64_t) DisplayHeight(dpy, screen) + uy - height - border : uy; } else if (dmask & YValue) { if (dmask & YNegative) { - y = DisplayHeight(dpy, screen) + dy - height - 2 * (int) bwidth; + y = (int64_t) DisplayHeight(dpy, screen) + dy - height - border; rmask |= YNegative; } else { y = dy; @@ -1997,13 +2034,13 @@ int XWMGeometry( } if (x_return) - *x_return = x; + *x_return = clampInt64ToInt(x); if (y_return) - *y_return = y; + *y_return = clampInt64ToInt(y); if (width_return) - *width_return = width; + *width_return = clampInt64ToInt(width); if (height_return) - *height_return = height; + *height_return = clampInt64ToInt(height); if (gravity_return) { switch (rmask & (XNegative | YNegative)) { case 0: @@ -2023,6 +2060,95 @@ int XWMGeometry( return rmask; } +int XGeometry(Display *dpy, + int screen, + _Xconst char *position, + _Xconst char *default_position, + unsigned int bwidth, + unsigned int fwidth, + unsigned int fheight, + int xadder, + int yadder, + int *x_return, + int *y_return, + int *width_return, + int *height_return) +{ + int x = 0; + int y = 0; + unsigned int width = 0; + unsigned int height = 0; + int mask = default_position + ? XParseGeometry(default_position, &x, &y, &width, &height) + : NoValue; + int defaultMask = mask; + unsigned int defaultWidth = width; + unsigned int defaultHeight = height; + + int userX = 0; + int userY = 0; + unsigned int userWidth = 0; + unsigned int userHeight = 0; + int userMask = position ? XParseGeometry(position, &userX, &userY, + &userWidth, &userHeight) + : NoValue; + if (userMask & WidthValue) + width = userWidth; + if (userMask & HeightValue) + height = userHeight; + if (userMask & XValue) { + x = userX; + mask = (mask & ~XNegative) | (userMask & XNegative) | XValue; + } + if (userMask & YValue) { + y = userY; + mask = (mask & ~YNegative) | (userMask & YNegative) | YValue; + } + + /* Anchor dimensions feed the (-x, -y) corner math. Match upstream + * X.org XGeometry: when the USER spec supplies a position, anchor + * with the merged width (user override if any, else default). + * When the user spec has NO position, the default-position branch + * anchors with the DEFAULT width even if the user changed width. + * This matches xterm-style command-line geometry handling. + * Routing through satMulAdd so a pathological spec cannot wrap + * int64_t before the clamp. */ + unsigned int xAnchorWidth = + (userMask & XValue) || !(defaultMask & WidthValue) ? width + : defaultWidth; + unsigned int yAnchorHeight = + (userMask & YValue) || !(defaultMask & HeightValue) ? height + : defaultHeight; + int64_t xAnchorPixelWidth = + satMulAdd((int64_t) xAnchorWidth, (int64_t) fwidth, (int64_t) xadder); + int64_t yAnchorPixelHeight = + satMulAdd((int64_t) yAnchorHeight, (int64_t) fheight, (int64_t) yadder); + int64_t border = 2 * (int64_t) bwidth; + if (x_return) { + int64_t v = 0; + if (mask & XValue) { + v = (mask & XNegative) ? (int64_t) DisplayWidth(dpy, screen) - + xAnchorPixelWidth - border + x + : (int64_t) x; + } + *x_return = clampInt64ToInt(v); + } + if (y_return) { + int64_t v = 0; + if (mask & YValue) { + v = (mask & YNegative) ? (int64_t) DisplayHeight(dpy, screen) - + yAnchorPixelHeight - border + y + : (int64_t) y; + } + *y_return = clampInt64ToInt(v); + } + if (width_return) + *width_return = clampInt64ToInt((int64_t) width); + if (height_return) + *height_return = clampInt64ToInt((int64_t) height); + return userMask; +} + Status XGetIconSizes( Display *dpy, Window w, @@ -3295,9 +3421,12 @@ Status XmbTextPerCharExtents(XFontSet font_set, XRectangle *max_logical_extents) { CompatFontSet *set = GET_FONT_SET(font_set); + /* Clamp both sides at zero: a negative buffer_size would otherwise + * propagate into count and report a negative num_chars. */ int count = text_len > 0 ? text_len : 0; - if (count > buffer_size) - count = buffer_size; + int cap = buffer_size > 0 ? buffer_size : 0; + if (count > cap) + count = cap; int charWidth = set && set->font ? set->font->max_bounds.width : 8; int height = set && set->font ? set->font->ascent + set->font->descent : 0; int ascent = set && set->font ? set->font->ascent : 0; diff --git a/src/path/compose.c b/src/path/compose.c index d2ccc40..813710c 100644 --- a/src/path/compose.c +++ b/src/path/compose.c @@ -112,6 +112,11 @@ Bool pathComposeSpansToBuffer(Uint32 *buffer, return False; } + /* Promote core X11 pixels (alpha byte == 0) to opaque before they reach + * the pixman fill or the per-span blend loop; otherwise primitives that + * use BlackPixel would write transparent destinations. */ + color = colorWithOpaqueDefault(color); + Uint32 src = colorToRgba8888(color); Bool usedPixman = False; if (!fillFullCoverageSpansWithPixman(buffer, width, height, spans, color, diff --git a/src/pixmap.c b/src/pixmap.c index 2b1f3ef..98b0229 100644 --- a/src/pixmap.c +++ b/src/pixmap.c @@ -24,6 +24,7 @@ static Bool isSupportedPixmapDepth(unsigned int depth) static Uint32 mapPixel(SDL_PixelFormat *format, unsigned long pixel) { + pixel = colorWithOpaqueDefault(pixel); return SDL_MapRGBA(format, GET_RED_FROM_COLOR(pixel), GET_GREEN_FROM_COLOR(pixel), GET_BLUE_FROM_COLOR(pixel), GET_ALPHA_FROM_COLOR(pixel)); @@ -93,7 +94,6 @@ Pixmap XCreatePixmap(Display *display, handleOutOfMemory(0, display, 0, 0); return None; } - LOG("%s: addr= %lu, w = %d, h = %d\n", __func__, pixmap, width, height); PixmapStruct *pixmapStruct = malloc(sizeof(PixmapStruct)); if (!pixmapStruct) { FREE_XID(pixmap); diff --git a/src/pointer.c b/src/pointer.c index 9e38ead..b0af71e 100644 --- a/src/pointer.c +++ b/src/pointer.c @@ -1,10 +1,14 @@ #include "X11/Xlib.h" #include +#include +#include +#include #include #include "window-internal.h" #include "window.h" #include "events.h" #include "display.h" +#include "errors.h" unsigned int currentEventMask = ~0; Bool mouseFrozen = False; @@ -17,12 +21,217 @@ typedef struct { unsigned int event_mask; Window confine_to; Cursor cursor; + Cursor savedCursor; /* cursor before XDefineCursor override */ + Bool cursorWasOverridden; int pointer_mode; int keyboard_mode; + Bool passive; } PointerGrabState; static PointerGrabState pointerGrab; +typedef struct ButtonGrab { + unsigned int button; + unsigned int modifiers; + Window grab_window; + Bool owner_events; + unsigned int event_mask; + int pointer_mode; + int keyboard_mode; + Window confine_to; + Cursor cursor; + struct ButtonGrab *next; +} ButtonGrab; + +static ButtonGrab *buttonGrabs = NULL; + +Window getGrabbedPointerWindow(void) +{ + return pointerGrab.active ? pointerGrab.grab_window : None; +} + +Bool getPointerGrabOwnerEvents(void) +{ + return pointerGrab.active ? pointerGrab.owner_events : False; +} + +unsigned int getPointerGrabEventMask(void) +{ + return pointerGrab.active ? pointerGrab.event_mask : 0; +} + +static Bool windowContainsDescendant(Window ancestor, Window descendant) +{ + while (descendant != None && IS_TYPE(descendant, WINDOW)) { + if (descendant == ancestor) + return True; + descendant = GET_PARENT(descendant); + } + return False; +} + +static void installPointerGrab(Display *display, + Window grab_window, + Bool owner_events, + unsigned int event_mask, + int pointer_mode, + int keyboard_mode, + Window confine_to, + Cursor cursor, + Bool passive) +{ + pointerGrab.active = True; + pointerGrab.grab_window = grab_window; + pointerGrab.owner_events = owner_events; + pointerGrab.event_mask = event_mask; + pointerGrab.confine_to = confine_to; + pointerGrab.cursor = cursor; + pointerGrab.pointer_mode = pointer_mode; + pointerGrab.keyboard_mode = keyboard_mode; + pointerGrab.passive = passive; + currentEventMask = event_mask; + mouseFrozen = pointer_mode == GrabModeSync; + keyboardFrozen = keyboard_mode == GrabModeSync; + if (confine_to != None && IS_TYPE(confine_to, WINDOW) && + IS_MAPPED_TOP_LEVEL_WINDOW(confine_to)) + SDL_SetWindowGrab(GET_WINDOW_STRUCT(confine_to)->sdlWindow, SDL_TRUE); + pointerGrab.cursorWasOverridden = False; + pointerGrab.savedCursor = None; + if (cursor != None && IS_TYPE(grab_window, WINDOW)) { + /* Capture the window's previous cursor so XUngrabPointer can + * restore it; XDefineCursor otherwise persists the grab cursor + * after ungrab. */ + pointerGrab.savedCursor = GET_WINDOW_STRUCT(grab_window)->cursor; + pointerGrab.cursorWasOverridden = True; + XDefineCursor(display, grab_window, cursor); + } + postCrossingEvent(display, grab_window, EnterNotify, NotifyGrab, + NotifyAncestor, convertModifierState(SDL_GetModState())); +} + +Bool activatePassiveButtonGrab(Display *display, + Window root, + int root_x, + int root_y, + unsigned int button, + unsigned int state) +{ + if (pointerGrab.active) + return False; + + (void) display; + Window containing = getContainingWindow(root, root_x, root_y); + + unsigned int modifiers = + state & (ShiftMask | LockMask | ControlMask | Mod1Mask | Mod2Mask | + Mod3Mask | Mod4Mask | Mod5Mask); + for (ButtonGrab *grab = buttonGrabs; grab; grab = grab->next) { + Bool buttonMatch = grab->button == AnyButton || grab->button == button; + Bool modMatch = + grab->modifiers == AnyModifier || grab->modifiers == modifiers; + if (buttonMatch && modMatch && + windowContainsDescendant(grab->grab_window, containing)) { + installPointerGrab(display, grab->grab_window, grab->owner_events, + grab->event_mask, grab->pointer_mode, + grab->keyboard_mode, grab->confine_to, + grab->cursor, True); + return True; + } + } + return False; +} + +Bool pointerGrabIsPassive(void) +{ + return pointerGrab.active && pointerGrab.passive; +} + +static void clearPointerGrab(Display *display) +{ + if (pointerGrab.confine_to != None && + IS_TYPE(pointerGrab.confine_to, WINDOW) && + IS_MAPPED_TOP_LEVEL_WINDOW(pointerGrab.confine_to)) { + SDL_SetWindowGrab(GET_WINDOW_STRUCT(pointerGrab.confine_to)->sdlWindow, + SDL_FALSE); + } + if (pointerGrab.cursorWasOverridden && pointerGrab.grab_window != None && + IS_TYPE(pointerGrab.grab_window, WINDOW)) { + XDefineCursor(display, pointerGrab.grab_window, + pointerGrab.savedCursor); + } + if (pointerGrab.active) { + postCrossingEvent(display, pointerGrab.grab_window, LeaveNotify, + NotifyUngrab, NotifyAncestor, + convertModifierState(SDL_GetModState())); + } + memset(&pointerGrab, 0, sizeof(pointerGrab)); + currentEventMask = ~0; + mouseFrozen = False; + keyboardFrozen = False; + clearActivePointerWindow(); +} + +static void replacePointerGrab(Display *display, + Window grab_window, + Bool owner_events, + unsigned int event_mask, + int pointer_mode, + int keyboard_mode, + Window confine_to, + Cursor cursor) +{ + if (pointerGrab.grab_window != grab_window) { + clearPointerGrab(display); + installPointerGrab(display, grab_window, owner_events, event_mask, + pointer_mode, keyboard_mode, confine_to, cursor, + False); + return; + } + + if (pointerGrab.confine_to != confine_to) { + if (pointerGrab.confine_to != None && + IS_TYPE(pointerGrab.confine_to, WINDOW) && + IS_MAPPED_TOP_LEVEL_WINDOW(pointerGrab.confine_to)) { + SDL_SetWindowGrab( + GET_WINDOW_STRUCT(pointerGrab.confine_to)->sdlWindow, + SDL_FALSE); + } + if (confine_to != None && IS_TYPE(confine_to, WINDOW) && + IS_MAPPED_TOP_LEVEL_WINDOW(confine_to)) { + SDL_SetWindowGrab(GET_WINDOW_STRUCT(confine_to)->sdlWindow, + SDL_TRUE); + } + } + if (cursor != None && !pointerGrab.cursorWasOverridden) { + pointerGrab.savedCursor = GET_WINDOW_STRUCT(grab_window)->cursor; + pointerGrab.cursorWasOverridden = True; + } + if (cursor != None) { + XDefineCursor(display, grab_window, cursor); + } else if (pointerGrab.cursorWasOverridden) { + XDefineCursor(display, grab_window, pointerGrab.savedCursor); + pointerGrab.cursorWasOverridden = False; + pointerGrab.savedCursor = None; + } + + pointerGrab.owner_events = owner_events; + pointerGrab.event_mask = event_mask; + pointerGrab.confine_to = confine_to; + pointerGrab.cursor = cursor; + pointerGrab.pointer_mode = pointer_mode; + pointerGrab.keyboard_mode = keyboard_mode; + pointerGrab.passive = False; + currentEventMask = event_mask; + mouseFrozen = pointer_mode == GrabModeSync; + keyboardFrozen = keyboard_mode == GrabModeSync; +} + +void releasePassivePointerGrab(Display *display) +{ + if (pointerGrabIsPassive()) + clearPointerGrab(display); +} + static void queryPointerRootPosition(Display *display, int *root_x, int *root_y) { SDL_Window *focus = SDL_GetMouseFocus(); @@ -93,11 +302,12 @@ int XWarpPointer(Display *display, ? win_h - (unsigned int) src_y : 0; } - /* X rect containment is half-open: x <= p < x + width. Use an - * unsigned-difference compare to avoid signed overflow when - * src_width approaches INT_MAX. */ - if (local_x < src_x || (unsigned int) (local_x - src_x) >= w || - local_y < src_y || (unsigned int) (local_y - src_y) >= h) { + /* X rect containment is half-open: x <= p < x + width. The + * difference runs through int64 so a local_x of INT_MIN and + * src_x of INT_MAX cannot wrap before the unsigned compare. */ + int64_t dx = (int64_t) local_x - (int64_t) src_x; + int64_t dy = (int64_t) local_y - (int64_t) src_y; + if (dx < 0 || dx >= (int64_t) w || dy < 0 || dy >= (int64_t) h) { return 1; } } @@ -112,13 +322,24 @@ int XWarpPointer(Display *display, &curr_x, &curr_y, NULL); } } + /* Compute final pointer in int64 and clamp to SDL's int arg range + * so callers cannot drive an overflow into the warp call. */ + int64_t finalX = (int64_t) curr_x + (int64_t) dest_x; + int64_t finalY = (int64_t) curr_y + (int64_t) dest_y; + if (finalX > INT_MAX) + finalX = INT_MAX; + if (finalX < INT_MIN) + finalX = INT_MIN; + if (finalY > INT_MAX) + finalY = INT_MAX; + if (finalY < INT_MIN) + finalY = INT_MIN; #if SDL_VERSION_ATLEAST(2, 0, 4) - if (SDL_WarpMouseGlobal(curr_x + dest_x, curr_y + dest_y) != 0) { + if (SDL_WarpMouseGlobal((int) finalX, (int) finalY) != 0) { LOG("Warning: SDL_WarpMouseGlobal failed: %s", SDL_GetError()); } #else - SDL_WarpMouseInWindow(SDL_GetMouseFocus(), curr_x + dest_x, - curr_y + dest_y); + SDL_WarpMouseInWindow(SDL_GetMouseFocus(), (int) finalX, (int) finalY); #endif return 1; } @@ -135,12 +356,44 @@ Bool XQueryPointer(Display *display, { // https://tronche.com/gui/x/xlib/window-information/XQueryPointer.html SET_X_SERVER_REQUEST(display, X_QueryPointer); - *root_return = SCREEN_WINDOW; - queryPointerRootPosition(display, root_x_return, root_y_return); - XTranslateCoordinates(display, SCREEN_WINDOW, window, *root_x_return, - *root_y_return, win_x_return, win_y_return, - child_return); - *mask_return = convertModifierState(SDL_GetModState()); + TYPE_CHECK(window, WINDOW, display, False); + if (root_return) + *root_return = SCREEN_WINDOW; + int root_x = 0, root_y = 0; + queryPointerRootPosition(display, &root_x, &root_y); + if (root_x_return) + *root_x_return = root_x; + if (root_y_return) + *root_y_return = root_y; + int win_x = 0, win_y = 0; + Window child = None; + XTranslateCoordinates(display, SCREEN_WINDOW, window, root_x, root_y, + &win_x, &win_y, &child); + if (win_x_return) + *win_x_return = win_x; + if (win_y_return) + *win_y_return = win_y; + if (child_return) + *child_return = child; + if (mask_return) { + /* Real XQueryPointer reports modifier state in the low byte + * and pressed button state in the high byte (Button[1-5]Mask). + * SDL_GetMouseState returns a per-button bitmask; project each + * SDL button bit onto the matching X mask. */ + unsigned int mask = convertModifierState(SDL_GetModState()); + Uint32 sdlButtons = SDL_GetMouseState(NULL, NULL); + if (sdlButtons & SDL_BUTTON(SDL_BUTTON_LEFT)) + mask |= Button1Mask; + if (sdlButtons & SDL_BUTTON(SDL_BUTTON_MIDDLE)) + mask |= Button2Mask; + if (sdlButtons & SDL_BUTTON(SDL_BUTTON_RIGHT)) + mask |= Button3Mask; + if (sdlButtons & SDL_BUTTON(SDL_BUTTON_X1)) + mask |= Button4Mask; + if (sdlButtons & SDL_BUTTON(SDL_BUTTON_X2)) + mask |= Button5Mask; + *mask_return = mask; + } return True; } @@ -158,53 +411,137 @@ int XGrabPointer(Display *display, SET_X_SERVER_REQUEST(display, X_GrabPointer); /* Xlib status codes: GrabSuccess == 0, AlreadyGrabbed == 1, * GrabInvalidTime == 2, GrabNotViewable == 3, GrabFrozen == 4. - * Returning a bare `1` for success would be read by Xlib clients as - * AlreadyGrabbed and route them down the wrong recovery path. * - * Do not use SDL relative mouse mode for an X pointer grab. SDL relative - * mode hides the system cursor and reports relative deltas; an X grab only - * redirects/freezes pointer events while preserving normal cursor - * visibility. Motif pulldown menus rely on that distinction. - */ - pointerGrab.active = True; - pointerGrab.grab_window = grab_window; - pointerGrab.owner_events = owner_events; - pointerGrab.event_mask = event_mask; - pointerGrab.confine_to = confine_to; - pointerGrab.cursor = cursor; - pointerGrab.pointer_mode = pointer_mode; - pointerGrab.keyboard_mode = keyboard_mode; - currentEventMask = event_mask; - mouseFrozen = pointer_mode == GrabModeSync; - keyboardFrozen = keyboard_mode == GrabModeSync; - if (confine_to != None && IS_TYPE(confine_to, WINDOW) && - IS_MAPPED_TOP_LEVEL_WINDOW(confine_to)) - SDL_SetWindowGrab(GET_WINDOW_STRUCT(confine_to)->sdlWindow, SDL_TRUE); - if (cursor != None) - XDefineCursor(display, grab_window, cursor); - postCrossingEvent(display, grab_window, EnterNotify, NotifyGrab, - NotifyAncestor, convertModifierState(SDL_GetModState())); + * Do not use SDL relative mouse mode for an X pointer grab. SDL + * relative mode hides the system cursor and reports relative + * deltas; an X grab only redirects/freezes pointer events while + * preserving normal cursor visibility. Motif pulldown menus rely + * on that distinction. */ + if (grab_window == None || !IS_TYPE(grab_window, WINDOW)) + return BadWindow; + if (!isWindowEffectivelyViewable(grab_window)) + return GrabNotViewable; + if (confine_to != None && (!IS_TYPE(confine_to, WINDOW) || + !isWindowEffectivelyViewable(confine_to))) + return GrabNotViewable; + /* AlreadyGrabbed applies when another client owns the active grab. This + * SDL-backed implementation has a single in-process client, so a second + * explicit grab is a same-client regrab and must update the active grab. + * Motif menu bars use that to switch pointer modes while posting a menu. */ + if (pointerGrab.active && !pointerGrab.passive) { + replacePointerGrab(display, grab_window, owner_events, event_mask, + pointer_mode, keyboard_mode, confine_to, cursor); + return GrabSuccess; + } + /* GrabInvalidTime: the requested time must be CurrentTime or no + * later than the last server time. Compare as CARD32 with a + * signed delta so a 32-bit tick rollover (every ~49 days of + * uptime) doesn't spuriously fire. Time is unsigned long (LP64: + * 64 bits) but server time is delivered as a 32-bit X protocol + * value, so masking to 32 bits before the compare keeps both + * sides in the same domain. */ + if (time != CurrentTime) { + Uint32 now = SDL_GetTicks(); + Uint32 requested = (Uint32) time; + if ((Sint32) (requested - now) > 0) + return GrabInvalidTime; + } + if (pointerGrab.passive) + clearPointerGrab(display); + installPointerGrab(display, grab_window, owner_events, event_mask, + pointer_mode, keyboard_mode, confine_to, cursor, False); return GrabSuccess; } +int XGrabButton(Display *display, + unsigned int button, + unsigned int modifiers, + Window grab_window, + Bool owner_events, + unsigned int event_mask, + int pointer_mode, + int keyboard_mode, + Window confine_to, + Cursor cursor) +{ + SET_X_SERVER_REQUEST(display, X_GrabButton); + TYPE_CHECK(grab_window, WINDOW, display, BadWindow); + /* Xlib semantics: a passive grab on the same (button, modifiers, + * grab_window) triple replaces the previous one. Appending would + * silently leak the prior grab and route events through whichever + * matched first. */ + ButtonGrab *grab = NULL; + for (ButtonGrab *existing = buttonGrabs; existing; + existing = existing->next) { + if (existing->button == button && existing->modifiers == modifiers && + existing->grab_window == grab_window) { + grab = existing; + break; + } + } + if (!grab) { + grab = calloc(1, sizeof(*grab)); + if (!grab) + return BadAlloc; + grab->next = buttonGrabs; + buttonGrabs = grab; + } + grab->button = button; + grab->modifiers = modifiers; + grab->grab_window = grab_window; + grab->owner_events = owner_events; + grab->event_mask = event_mask; + grab->pointer_mode = pointer_mode; + grab->keyboard_mode = keyboard_mode; + grab->confine_to = confine_to; + grab->cursor = cursor; + return Success; +} + +int XUngrabButton(Display *display, + unsigned int button, + unsigned int modifiers, + Window grab_window) +{ + SET_X_SERVER_REQUEST(display, X_UngrabButton); + ButtonGrab **p = &buttonGrabs; + while (*p) { + ButtonGrab *grab = *p; + Bool buttonMatch = button == AnyButton || grab->button == button; + Bool modMatch = + modifiers == AnyModifier || grab->modifiers == modifiers; + Bool winMatch = grab->grab_window == grab_window; + if (buttonMatch && modMatch && winMatch) { + *p = grab->next; + free(grab); + } else { + p = &grab->next; + } + } + return Success; +} + +/* Drop any passive button grabs that reference `window` so a later + * XID reuse cannot misroute events through a stale grab entry. + * Called from destroyWindow when a window is torn down. */ +void releaseButtonGrabsForWindow(Window window) +{ + ButtonGrab **p = &buttonGrabs; + while (*p) { + ButtonGrab *grab = *p; + if (grab->grab_window == window || grab->confine_to == window) { + *p = grab->next; + free(grab); + } else { + p = &grab->next; + } + } +} + int XUngrabPointer(Display *display, Time time) { // https://tronche.com/gui/x/xlib/input/XUngrabPointer.html SET_X_SERVER_REQUEST(display, X_UngrabPointer); - if (pointerGrab.confine_to != None && - IS_TYPE(pointerGrab.confine_to, WINDOW) && - IS_MAPPED_TOP_LEVEL_WINDOW(pointerGrab.confine_to)) { - SDL_SetWindowGrab(GET_WINDOW_STRUCT(pointerGrab.confine_to)->sdlWindow, - SDL_FALSE); - } - if (pointerGrab.active) { - postCrossingEvent(display, pointerGrab.grab_window, LeaveNotify, - NotifyUngrab, NotifyAncestor, - convertModifierState(SDL_GetModState())); - } - memset(&pointerGrab, 0, sizeof(pointerGrab)); - currentEventMask = ~0; - mouseFrozen = False; - keyboardFrozen = False; + clearPointerGrab(display); return 1; } diff --git a/src/replay-target.c b/src/replay-target.c new file mode 100644 index 0000000..0700ebb --- /dev/null +++ b/src/replay-target.c @@ -0,0 +1,188 @@ +#include "replay-target.h" +#include +#include "util.h" + +static SDL_atomic_t targetWindowId; +static SDL_atomic_t targetRootX; +static SDL_atomic_t targetRootY; +/* Seqlock counter: writers bump it to odd before mutating the (id, rootX, + * rootY) triple and back to even afterward, so a reader that observes a + * stable even value before and after its loads has a coherent snapshot. */ +static SDL_atomic_t targetSeq; +static SDL_atomic_t lastPointerX; +static SDL_atomic_t lastPointerY; +static SDL_atomic_t lastPointerRootX; +static SDL_atomic_t lastPointerRootY; +static SDL_atomic_t hasPointer; + +/* Read/written only from the main thread while top-level windows map/unmap. */ +static unsigned long targetArea = 0; +static unsigned long targetAreaHighWater = 0; + +#define REPLAY_TARGET_HIGH_WATER_NUMERATOR 1 +#define REPLAY_TARGET_HIGH_WATER_DENOMINATOR 2 + +static void rebasePointerToTargetRoot(int rootX, int rootY) +{ + if (!SDL_AtomicGet(&hasPointer)) + return; + int pointerRootX = SDL_AtomicGet(&lastPointerRootX); + int pointerRootY = SDL_AtomicGet(&lastPointerRootY); + SDL_AtomicSet(&lastPointerX, pointerRootX - rootX); + SDL_AtomicSet(&lastPointerY, pointerRootY - rootY); +} + +/* Bump the seq counter by one. Writers call this to bracket their writes + * to the target triple; the parity convention is "odd = write in + * progress" so a reader observing an odd value (or a change across its + * before/after reads) knows to retry. */ +static void seqBumpLocked(void) +{ + SDL_AtomicAdd(&targetSeq, 1); +} + +void replayTargetOfferWindow(Uint32 sdlWindowId, + int rootX, + int rootY, + int width, + int height) +{ + unsigned long area = (unsigned long) (width > 0 ? width : 0) * + (unsigned long) (height > 0 ? height : 0); + if (sdlWindowId == 0) + return; + + Uint32 currentTarget = (Uint32) SDL_AtomicGet(&targetWindowId); + if (targetArea != 0 && area < targetArea && currentTarget != sdlWindowId) + return; + + if (targetArea == 0 && targetAreaHighWater > 0) { + unsigned long floor = + (targetAreaHighWater * REPLAY_TARGET_HIGH_WATER_NUMERATOR) / + REPLAY_TARGET_HIGH_WATER_DENOMINATOR; + if (area < floor) { + LOG("replay-target: deferring candidate id=%u (%dx%d, area=%lu) " + "below high-water floor %lu\n", + sdlWindowId, width, height, area, floor); + return; + } + } + + targetArea = area; + if (area > targetAreaHighWater) + targetAreaHighWater = area; + rebasePointerToTargetRoot(rootX, rootY); + /* Bracket the triple write with seq bumps so readers see either the + * old triple or the new one, never a mix. */ + seqBumpLocked(); + SDL_AtomicSet(&targetRootX, rootX); + SDL_AtomicSet(&targetRootY, rootY); + SDL_AtomicSet(&targetWindowId, (int) sdlWindowId); + seqBumpLocked(); + LOG("replay-target: target window id=%u at %+d%+d (%dx%d)\n", sdlWindowId, + rootX, rootY, width, height); +} + +void replayTargetForgetWindow(Uint32 sdlWindowId) +{ + if (sdlWindowId == 0) + return; + if ((Uint32) SDL_AtomicGet(&targetWindowId) != sdlWindowId) + return; + seqBumpLocked(); + SDL_AtomicSet(&targetWindowId, 0); + targetArea = 0; + seqBumpLocked(); + LOG("replay-target: target window id=%u retired (high-water %lu kept)\n", + sdlWindowId, targetAreaHighWater); +} + +Uint32 replayTargetWindowId(void) +{ + return (Uint32) SDL_AtomicGet(&targetWindowId); +} + +void replayTargetRootToLocal(int rootX, int rootY, int *localX, int *localY) +{ + /* Best-effort single-field translation. Callers that need the id and + * the local coords as a coherent pair must go through + * replayTargetTranslateRoot() instead. */ + *localX = rootX - SDL_AtomicGet(&targetRootX); + *localY = rootY - SDL_AtomicGet(&targetRootY); +} + +Bool replayTargetTranslateRoot(int rootX, + int rootY, + Uint32 *winId, + int *localX, + int *localY) +{ + /* Seqlock read with a small retry budget: the writer side bumps an + * even counter to odd before mutating the triple and back to even + * after. A reader observes a coherent snapshot iff the counter is + * even and unchanged across the before/after loads. */ + for (int attempt = 0; attempt < 8; attempt++) { + int seqBefore = SDL_AtomicGet(&targetSeq); + if (seqBefore & 1) + continue; + Uint32 id = (Uint32) SDL_AtomicGet(&targetWindowId); + int rx = SDL_AtomicGet(&targetRootX); + int ry = SDL_AtomicGet(&targetRootY); + int seqAfter = SDL_AtomicGet(&targetSeq); + if (seqBefore != seqAfter) + continue; + if (id == 0) + return False; + if (winId) + *winId = id; + if (localX) + *localX = rootX - rx; + if (localY) + *localY = rootY - ry; + return True; + } + return False; +} + +void replayTargetRememberPointer(int x, int y) +{ + /* Read targetRoot via the seqlock so a concurrent retarget does not + * combine the new (x,y) local with the previous target's root + * origin. If no coherent snapshot is reachable, fall back to a + * single best-effort read of the current values; staying at (0, 0) + * after a busy writer would otherwise pin lastPointerRoot at the + * local coords and the next rebasePointerToTargetRoot() would + * convert a local coordinate as if it were root-relative. */ + int rx = 0, ry = 0; + Bool gotSnapshot = False; + for (int attempt = 0; attempt < 8 && !gotSnapshot; attempt++) { + int seqBefore = SDL_AtomicGet(&targetSeq); + if (seqBefore & 1) + continue; + rx = SDL_AtomicGet(&targetRootX); + ry = SDL_AtomicGet(&targetRootY); + int seqAfter = SDL_AtomicGet(&targetSeq); + if (seqBefore == seqAfter) + gotSnapshot = True; + } + if (!gotSnapshot) { + rx = SDL_AtomicGet(&targetRootX); + ry = SDL_AtomicGet(&targetRootY); + } + SDL_AtomicSet(&lastPointerX, x); + SDL_AtomicSet(&lastPointerY, y); + SDL_AtomicSet(&lastPointerRootX, rx + x); + SDL_AtomicSet(&lastPointerRootY, ry + y); + SDL_AtomicSet(&hasPointer, 1); +} + +Bool replayTargetReadPointer(int *x, int *y) +{ + if (!SDL_AtomicGet(&hasPointer)) + return False; + if (x) + *x = SDL_AtomicGet(&lastPointerX); + if (y) + *y = SDL_AtomicGet(&lastPointerY); + return True; +} diff --git a/src/replay-target.h b/src/replay-target.h new file mode 100644 index 0000000..22432e8 --- /dev/null +++ b/src/replay-target.h @@ -0,0 +1,34 @@ +#ifndef LIBX11_COMPAT_REPLAY_TARGET_H +#define LIBX11_COMPAT_REPLAY_TARGET_H + +#include +#include + +/* Shared target state for in-process replay, XTest injection, snapshots, + * and replay-driven resize. The target is captured on the main thread when + * top-level SDL windows are mapped; background replay/XTest callers then use + * the cached SDL id without walking the live X window tree. + */ +void replayTargetOfferWindow(Uint32 sdlWindowId, + int rootX, + int rootY, + int width, + int height); +void replayTargetForgetWindow(Uint32 sdlWindowId); +Uint32 replayTargetWindowId(void); +void replayTargetRootToLocal(int rootX, int rootY, int *localX, int *localY); +/* Coherent snapshot of the (target id, target root) triple. Returns False + * when no target is currently registered. Callers that need both the id + * and the local-coord translation should prefer this over the separate + * replayTargetWindowId / replayTargetRootToLocal pair, since the latter + * pair can read a half-retargeted state where id and root come from + * different generations. */ +Bool replayTargetTranslateRoot(int rootX, + int rootY, + Uint32 *winId, + int *localX, + int *localY); +void replayTargetRememberPointer(int x, int y); +Bool replayTargetReadPointer(int *x, int *y); + +#endif diff --git a/src/replay.c b/src/replay.c new file mode 100644 index 0000000..13c04a6 --- /dev/null +++ b/src/replay.c @@ -0,0 +1,262 @@ +/* Scripted event replay for libx11-compat. + * + * Read a small command file pointed to by $LIBX11_COMPAT_REPLAY and + * dispatch its instructions as synthetic input via the XTest fake-event + * API. Each line is a single command; whitespace separates fields; blank + * lines and lines starting with # are ignored. + * + * Supported commands: + * delay + * motion + * button press|release + * click # motion + button 1 press + release + * key press|release + * + * Why this exists: on macOS, external event-injection tools (cliclick, + * CGEvent, AppleScript click) cannot reliably push mouse button events + * into SDL windows belonging to non-bundled, non-keyWindow processes. + * The replay engine runs inside the target process, hands its synthetic + * SDL_Event to SDL_PushEvent, and the event flows through the normal + * onSdlEvent + convertEvent path, identical to a real click. The + * macOS NSEvent dispatch chain is never involved, so the limitation + * does not apply. + * + * Threading: parse runs in a detached pthread. SDL_PushEvent is + * thread-safe; the parser otherwise touches no libx11-compat state. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "replay.h" +#include "snapshot.h" +#include "util.h" + +static pthread_once_t replayOnce = PTHREAD_ONCE_INIT; +static Display *replayDisplay = NULL; +static char *replayPath = NULL; +/* Set to non-zero by replayStop() (from the last XCloseDisplay) to + * signal the worker pthread that the host Display is going away. The + * replay thread polls this between commands and inside its delay + * chunks, then exits cleanly. Replay/XTest injection also stops accepting + * events once replay-target clears the cached target, so this flag plus the + * join in replayStop() guarantees no pushed event survives SDL_Quit. + */ +static SDL_atomic_t replayShouldStop; +/* Thread handle so replayStop can join the worker before the caller + * tears down SDL. Without the join the worker -- previously detached + * -- could fire SDL_PushEvent into a freed SDL state after SDL_Quit + * returned (gemini-flagged race). spawned tracks whether the handle + * is valid, avoiding pthread_join on PTHREAD_CANCELED-style stubs. */ +static pthread_t replayThreadHandle; +static Bool replayThreadSpawned = False; + +static void trim(char *s) +{ + char *end = s + strlen(s); + while (end > s && isspace((unsigned char) end[-1])) + --end; + *end = '\0'; +} + +static Bool isPressWord(const char *dir) +{ + return strcasecmp(dir, "press") == 0 || strcasecmp(dir, "down") == 0; +} + +/* Replay diagnostics go to stderr unconditionally rather than through LOG(). + * $LIBX11_COMPAT_REPLAY is an end-user / test-driver control path; silently + * dropping "file not found" or "unknown command" errors in release builds + * would make script issues nearly impossible to diagnose. */ +static void runScript(const char *path) +{ + FILE *fp = fopen(path, "r"); + if (!fp) { + fprintf(stderr, "replay: cannot open '%s': %s\n", path, + strerror(errno)); + return; + } + /* Lines longer than the buffer are split across fgets() calls. Detect + * truncation (no trailing newline before EOF) and drain to the next \n + * so the tail of an overlong line is never reparsed as a fresh command. + */ + char line[1024]; + int lineno = 0; + while (fgets(line, sizeof(line), fp)) { + if (SDL_AtomicGet(&replayShouldStop)) + break; + lineno++; + size_t len = strlen(line); + Bool truncated = (len == sizeof(line) - 1 && line[len - 1] != '\n'); + if (truncated) { + int c; + while ((c = fgetc(fp)) != '\n' && c != EOF) + ; + fprintf(stderr, "replay: line %d too long; truncated and skipped\n", + lineno); + continue; + } + trim(line); + char *p = line; + while (*p == ' ' || *p == '\t') + p++; + if (*p == '\0' || *p == '#') + continue; + + char cmd[32]; + int consumed = 0; + if (sscanf(p, "%31s%n", cmd, &consumed) != 1) + continue; + char *args = p + consumed; + while (*args == ' ' || *args == '\t') + args++; + + if (!strcmp(cmd, "delay")) { + /* Sleep in small chunks so replayStop() (joined from + * XCloseDisplay before SDL_Quit) interrupts a long delay + * within at most one chunk instead of blocking the + * X-client main thread for the full requested duration. + * Use nanosleep's remaining-time output to handle EINTR + * resumption -- a signal mid-sleep otherwise short-changes + * the delay because the loop would decrement by the + * requested slice rather than the actual time slept, + * making smoke timing nondeterministic (codex-flagged). */ + unsigned long ms = strtoul(args, NULL, 10); + const unsigned long chunk_ms = 50; + while (ms > 0 && !SDL_AtomicGet(&replayShouldStop)) { + unsigned long slice = ms > chunk_ms ? chunk_ms : ms; + struct timespec req = {0, (long) (slice * 1000000L)}; + struct timespec rem; + while (nanosleep(&req, &rem) == -1 && errno == EINTR) { + if (SDL_AtomicGet(&replayShouldStop)) + break; + req = rem; + } + ms -= slice; + } + } else if (!strcmp(cmd, "motion")) { + int x = 0, y = 0; + if (sscanf(args, "%d %d", &x, &y) == 2) { + XTestFakeMotionEvent(replayDisplay, 0, x, y, 0); + } + } else if (!strcmp(cmd, "button")) { + unsigned int btn = 0; + char dir[16] = {0}; + if (sscanf(args, "%u %15s", &btn, dir) == 2) { + XTestFakeButtonEvent(replayDisplay, btn, isPressWord(dir), 0); + } + } else if (!strcmp(cmd, "click")) { + int x = 0, y = 0; + if (sscanf(args, "%d %d", &x, &y) == 2) { + XTestFakeMotionEvent(replayDisplay, 0, x, y, 0); + XTestFakeButtonEvent(replayDisplay, 1, True, 10); + XTestFakeButtonEvent(replayDisplay, 1, False, 10); + } + } else if (!strcmp(cmd, "key")) { + unsigned int code = 0; + char dir[16] = {0}; + if (sscanf(args, "%u %15s", &code, dir) == 2) + XTestFakeKeyEvent(replayDisplay, code, isPressWord(dir), 0); + } else if (!strcmp(cmd, "snapshot")) { + /* Capture the cached replay target window's backing surface + * to the path that follows. Blocking: the main thread does + * the actual save so we know the BMP exists by the time + * this command returns. The runner inspects these files + * after runScript completes. */ + if (*args == '\0') { + fprintf(stderr, "replay: line %d: snapshot needs a path\n", + lineno); + } else { + int rc = snapshotRequestAndWait(args); + if (rc != 0) + fprintf(stderr, + "replay: line %d: snapshot %s failed (rc=%d)\n", + lineno, args, rc); + } + } else if (!strcmp(cmd, "resize")) { + /* Resize the cached replay target window. Drives + * SDL_SetWindowSize on the main thread, which the SDL + * Cocoa backend translates into a window-resize event + * that surfaces as ConfigureNotify for the X client. */ + int w = 0, h = 0; + if (sscanf(args, "%d %d", &w, &h) == 2) { + int rc = snapshotRequestResizeAndWait(w, h); + if (rc != 0) + fprintf(stderr, + "replay: line %d: resize %dx%d failed (rc=%d)\n", + lineno, w, h, rc); + } else { + fprintf(stderr, "replay: line %d: resize expects W H\n", + lineno); + } + } else { + fprintf(stderr, "replay: line %d: unknown command '%s'\n", lineno, + cmd); + } + } + fclose(fp); +} + +static void *replayThread(void *unused) +{ + (void) unused; + LOG("replay: thread started, path=%s\n", replayPath); + /* Brief settle delay so XMapWindow's host realization and the first + * SDL_PumpEvents have finished before the script starts injecting. */ + struct timespec ts = {0, 300000000L}; + nanosleep(&ts, NULL); + LOG("replay: starting script\n"); + runScript(replayPath); + LOG("replay: script done\n"); + return NULL; +} + +static void initOnce(void) +{ + const char *path = getenv("LIBX11_COMPAT_REPLAY"); + if (!path || !*path) + return; + replayPath = strdup(path); + if (!replayPath) + return; + int rc = pthread_create(&replayThreadHandle, NULL, replayThread, NULL); + if (rc != 0) + LOG("replay: pthread_create failed: %d\n", rc); + else + replayThreadSpawned = True; +} + +/* Capture the Display once on the first call; later calls (e.g. subsequent + * top-level mappings) must not overwrite a pointer the background thread + * may already be dereferencing. pthread_once still gates the actual thread + * spawn so any racing first-callers serialize through it. */ +void replayStartIfRequested(Display *display) +{ + if (!display) + return; + if (!replayDisplay) + replayDisplay = display; + pthread_once(&replayOnce, initOnce); +} + +void replayStop(void) +{ + SDL_AtomicSet(&replayShouldStop, 1); + /* Join synchronously so the caller (XCloseDisplay on the last + * display) can safely tear down SDL afterward. The worker polls + * replayShouldStop between commands and inside the chunked delay + * loop, so the join completes within ~50ms of the flag being set + * -- bounded enough to keep XCloseDisplay's latency reasonable. */ + if (replayThreadSpawned) { + pthread_join(replayThreadHandle, NULL); + replayThreadSpawned = False; + } +} diff --git a/src/replay.h b/src/replay.h new file mode 100644 index 0000000..dcd867e --- /dev/null +++ b/src/replay.h @@ -0,0 +1,17 @@ +#ifndef _LIBX11_COMPAT_REPLAY_H_ +#define _LIBX11_COMPAT_REPLAY_H_ + +#include + +/* If $LIBX11_COMPAT_REPLAY names a script file, spawn a background thread + * that injects its contents via XTest after the first top-level window is + * mapped. Idempotent: only the first invocation arms the thread. Pass the + * Display the script should target. */ +void replayStartIfRequested(Display *display); + +/* Signal the replay thread to exit at the next command boundary. Called + * from XCloseDisplay so a script with queued delay commands cannot keep + * firing into a torn-down Display. */ +void replayStop(void); + +#endif diff --git a/src/resource-types.h b/src/resource-types.h index 30dfe0a..93e51a1 100644 --- a/src/resource-types.h +++ b/src/resource-types.h @@ -28,7 +28,34 @@ typedef struct { #define GET_XID_TYPE(id) (((XID_Struct *) (id))->type) #define GET_XID_VALUE(id) (((XID_Struct *) (id))->dataPointer) +/* GET_WINDOW_STRUCT dereferences the XID's data pointer with no NULL + * guard. Debug builds trip an abort on misuse so the offending call + * site shows up in the test log instead of a SIGSEGV in unrelated + * frames. Release builds keep the bare deref to avoid any overhead on + * the hot path. Uses a GCC/Clang statement expression to evaluate the + * argument once. */ +#ifdef DEBUG_LIBX11_COMPAT +#include +#include +#define GET_WINDOW_STRUCT(window) \ + (__extension__({ \ + Window _gws_w = (window); \ + if (_gws_w == None) { \ + fprintf(stderr, "%s:%d: GET_WINDOW_STRUCT(None) in debug build\n", \ + __FILE__, __LINE__); \ + abort(); \ + } \ + WindowStruct *_gws_p = (WindowStruct *) GET_XID_VALUE(_gws_w); \ + if (!_gws_p) { \ + fprintf(stderr, "%s:%d: GET_WINDOW_STRUCT freed resource %lu\n", \ + __FILE__, __LINE__, (unsigned long) _gws_w); \ + abort(); \ + } \ + _gws_p; \ + })) +#else #define GET_WINDOW_STRUCT(window) ((WindowStruct *) GET_XID_VALUE(window)) +#endif #define IS_TYPE(resource, typeID) \ ((resource) != None && \ diff --git a/src/snapshot.c b/src/snapshot.c new file mode 100644 index 0000000..04bd6bb --- /dev/null +++ b/src/snapshot.c @@ -0,0 +1,224 @@ +/* In-process window snapshot for the smoke-test pipeline. + * + * Why this exists: macOS screencapture deactivates the frontmost + * NSApp briefly while it grabs pixels, which stalls SDL's Cocoa event + * pump just long enough that synthetic mouse/wheel events queued by + * the in-process replay engine never get drained. The result is + * screenshots that always show the pre-replay state (verified + * empirically -- 8 wheel events pushed, 8 filtered through, 0 + * delivered to the X client). Capturing inside the target process, + * directly from the SDL window surface, side-steps the entire + * NSApplication life-cycle. + * + * Threading: the replay parser runs on a detached pthread, but SDL's + * SDL_GetWindowSurface requires the thread that owns the renderer + * (in libx11-compat that is the X-client thread that called + * XOpenDisplay first). snapshotRequestAndWait() solves this by + * pushing an SDL_USEREVENT with code SNAPSHOT_EVENT_CODE; the main + * thread's convertEvent path detects that code and calls + * snapshotHandleEvent(), which performs the surface read on the + * correct thread and signals the waiter. + */ + +#include +#include +#include +#include +#include "drawing.h" +#include "events.h" +#include "replay-target.h" +#include "snapshot.h" +#include "window-internal.h" +#include "util.h" + +/* Single-snapshot serialization. Two replay scripts cannot run in the + * same process, and the replay parser issues snapshots sequentially, + * so a single mutex + condvar suffices. */ +static pthread_mutex_t snapshotMutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t snapshotCond = PTHREAD_COND_INITIALIZER; +static int snapshotResult = 0; +static int snapshotDone = 0; +/* SDL requires user-event types to be registered via SDL_RegisterEvents + * to be eligible for SDL_PushEvent + queue processing. Without + * registration the event survives the push but never appears at the + * other end of the queue (verified empirically -- raw SDL_USEREVENT + * pushes silently dropped before convertEvent saw them). Register + * lazily on first request so we share the per-process event-type + * registry with enqueueEvent's INTERNAL_EVENT_CODE pump. */ +static Uint32 snapshotEventType = (Uint32) -1; + +Bool snapshotOwnsEventType(Uint32 eventType) +{ + return snapshotEventType != (Uint32) -1 && eventType == snapshotEventType; +} + +int snapshotHandleEvent(const SDL_Event *event) +{ + char *path = (char *) event->user.data1; + int rc = 0; + Uint32 winId = replayTargetWindowId(); + SDL_Window *win = (winId != 0) ? SDL_GetWindowFromID(winId) : NULL; + if (!win) { + LOG("snapshot: no target window (winId=%u)\n", winId); + rc = -1; + goto signal; + } + drawWindowDataToScreen(); + SDL_Surface *surface = SDL_GetWindowSurface(win); + if (!surface) { + LOG("snapshot: SDL_GetWindowSurface failed: %s\n", SDL_GetError()); + rc = -3; + goto signal; + } + if (SDL_SaveBMP(surface, path) != 0) { + LOG("snapshot: SDL_SaveBMP(%s) failed: %s\n", path, SDL_GetError()); + rc = -3; + goto signal; + } + LOG("snapshot: wrote %s (%dx%d)\n", path, surface->w, surface->h); +signal: + pthread_mutex_lock(&snapshotMutex); + snapshotResult = rc; + snapshotDone = 1; + pthread_cond_signal(&snapshotCond); + pthread_mutex_unlock(&snapshotMutex); + free(path); + return rc; +} + +/* Resize round-trip uses the same global cond+mutex as snapshot since + * the replay engine issues both serially. A single SDL_RegisterEvents + * type id is shared with the snapshot path (the dispatch in + * convertEvent keys off user.code, not event type). */ +int snapshotHandleResizeEvent(Display *display, const SDL_Event *event) +{ + int width = (int) (intptr_t) event->user.data1; + int height = (int) (intptr_t) event->user.data2; + int rc = 0; + Uint32 winId = replayTargetWindowId(); + SDL_Window *win = (winId != 0) ? SDL_GetWindowFromID(winId) : NULL; + if (!win) { + LOG("resize: no target window (winId=%u)\n", winId); + rc = -1; + } else { + Window xwin = getWindowFromId(winId); + if (xwin == None) { + LOG("resize: no X window for SDL target %u\n", winId); + rc = -1; + goto signal; + } + SDL_SetWindowSize(win, width, height); + LOG("resize: target window %u -> %dx%d\n", winId, width, height); + postSyntheticWindowResize(display, xwin, width, height); + } +signal: + pthread_mutex_lock(&snapshotMutex); + snapshotResult = rc; + snapshotDone = 1; + pthread_cond_signal(&snapshotCond); + pthread_mutex_unlock(&snapshotMutex); + return rc; +} + +int snapshotRequestResizeAndWait(int width, int height) +{ + if (width <= 0 || height <= 0) + return -1; + pthread_mutex_lock(&snapshotMutex); + if (snapshotEventType == (Uint32) -1) { + snapshotEventType = SDL_RegisterEvents(1); + if (snapshotEventType == (Uint32) -1) { + pthread_mutex_unlock(&snapshotMutex); + LOG("resize: SDL_RegisterEvents failed: %s\n", SDL_GetError()); + return -2; + } + } + snapshotDone = 0; + snapshotResult = 0; + pthread_mutex_unlock(&snapshotMutex); + SDL_Event ev; + SDL_zero(ev); + ev.type = snapshotEventType; + ev.user.code = RESIZE_EVENT_CODE; + ev.user.data1 = (void *) (intptr_t) width; + ev.user.data2 = (void *) (intptr_t) height; + int pushRc = SDL_PushEvent(&ev); + LOG("resize: request %dx%d pushRc=%d\n", width, height, pushRc); + if (pushRc <= 0) + return -2; + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + /* Resize triggers a full reflow in Motif/Xt clients (ViolaWWW + * re-renders every visible paragraph), so the main thread may not + * drain SDL events for noticeably longer than a plain snapshot. + * 15s is generous enough to cover that without masking a real + * stuck-thread hang. */ + deadline.tv_sec += 15; + pthread_mutex_lock(&snapshotMutex); + int wait_rc = 0; + while (!snapshotDone && wait_rc == 0) + wait_rc = + pthread_cond_timedwait(&snapshotCond, &snapshotMutex, &deadline); + int rc = snapshotDone ? snapshotResult : -4; + pthread_mutex_unlock(&snapshotMutex); + LOG("resize: wait done rc=%d wait_rc=%d snapshotDone=%d\n", rc, wait_rc, + snapshotDone); + return rc; +} + +int snapshotRequestAndWait(const char *path) +{ + if (!path || !*path) + return -1; + char *copy = strdup(path); + if (!copy) + return -1; + pthread_mutex_lock(&snapshotMutex); + /* Lazy registration inside the lock: two concurrent first callers + * would otherwise each get their own type id from SDL, leaking one + * id and pushing events that no one is registered to handle + * (gemini-flagged race). */ + if (snapshotEventType == (Uint32) -1) { + snapshotEventType = SDL_RegisterEvents(1); + if (snapshotEventType == (Uint32) -1) { + pthread_mutex_unlock(&snapshotMutex); + LOG("snapshot: SDL_RegisterEvents failed: %s\n", SDL_GetError()); + free(copy); + return -2; + } + } + snapshotDone = 0; + snapshotResult = 0; + pthread_mutex_unlock(&snapshotMutex); + SDL_Event ev; + SDL_zero(ev); + ev.type = snapshotEventType; + ev.user.code = SNAPSHOT_EVENT_CODE; + ev.user.data1 = copy; + int pushRc = SDL_PushEvent(&ev); + LOG("snapshot: request path=%s type=%u pushRc=%d\n", path, + snapshotEventType, pushRc); + if (pushRc <= 0) { + free(copy); + return -2; + } + /* Bound the wait so a stuck main thread doesn't hang the replay + * indefinitely. The save itself is single-ms for a typical window, + * but the main thread may not drain the SDL_USEREVENT for a while + * if Motif is mid-reflow (e.g. right after a resize, or during + * the initial-expose burst of a freshly mapped XmMainWindow). 15s + * covers that without masking a genuine stuck-thread hang. */ + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += 15; + pthread_mutex_lock(&snapshotMutex); + int wait_rc = 0; + while (!snapshotDone && wait_rc == 0) + wait_rc = + pthread_cond_timedwait(&snapshotCond, &snapshotMutex, &deadline); + int rc = snapshotDone ? snapshotResult : -4; + pthread_mutex_unlock(&snapshotMutex); + LOG("snapshot: wait done rc=%d wait_rc=%d snapshotDone=%d\n", rc, wait_rc, + snapshotDone); + return rc; +} diff --git a/src/snapshot.h b/src/snapshot.h new file mode 100644 index 0000000..0659fc5 --- /dev/null +++ b/src/snapshot.h @@ -0,0 +1,46 @@ +#ifndef _LIBX11_COMPAT_SNAPSHOT_INTERNAL_H_ +#define _LIBX11_COMPAT_SNAPSHOT_INTERNAL_H_ + +#include +#include + +/* Request a snapshot of the replay target SDL window's backing surface to + * path (BMP, format chosen by SDL_SaveBMP). Blocks the calling thread + * until the main thread finishes the save and signals completion, then + * returns. Safe to call from any thread; the actual file I/O always + * happens on the thread that runs the X event loop (the same thread that + * owns the SDL renderer), satisfying SDL's main-thread requirement for + * SDL_GetWindowSurface. + * + * Returns 0 on success, -1 if no target window is cached, -2 if the + * SDL_Event push failed, -3 if the save itself failed. The reason the + * call is synchronous (and not fire-and-forget): the smoke test runner + * inspects the saved file immediately after replay completes, so the + * BMP must exist by the time replayStartIfRequested's background thread + * returns from runScript. + */ +int snapshotRequestAndWait(const char *path); + +/* Handle a SNAPSHOT_EVENT_CODE SDL_USEREVENT on the main thread. Reads + * the path from event->user.data1, saves the cached replay target's + * window surface to it, frees the path string, and signals any waiter + * in snapshotRequestAndWait. Returns 0 on success, non-zero on + * snapshot failure (still signals the waiter so the call returns). + */ +int snapshotHandleEvent(const SDL_Event *event); + +/* Resize the cached replay target window to (width, height). Blocks the + * caller until SDL_SetWindowSize completes on the main thread, since + * SDL_SetWindowSize is main-thread-only on macOS. Used by the replay + * engine's resize command. Returns 0 on success, -1 if no target + * window, -2 if the SDL_Event push failed. + */ +int snapshotRequestResizeAndWait(int width, int height); +Bool snapshotOwnsEventType(Uint32 eventType); + +/* Handle a RESIZE_EVENT_CODE SDL_USEREVENT on the main thread. + * Decodes width/height from data1/data2, calls SDL_SetWindowSize, + * signals the waiter. Returns 0 on success. */ +int snapshotHandleResizeEvent(Display *display, const SDL_Event *event); + +#endif diff --git a/src/window-internal.c b/src/window-internal.c index bfb24e4..9621a0f 100644 --- a/src/window-internal.c +++ b/src/window-internal.c @@ -5,7 +5,9 @@ #include "display.h" #include "font.h" #include "image.h" +#include "input.h" #include "colors.h" +#include "replay-target.h" Window SCREEN_WINDOW = None; @@ -158,7 +160,7 @@ static SDL_mutex *mappingListLock = NULL; * path before SDL_Window / event-pump threads can reach the register / * lookup helpers; subsequent callers are harmless no-ops. If * SDL_CreateMutex fails we leave the lock NULL and the lock/unlock - * wrappers below skip the SDL call — better than crashing on an + * wrappers below skip the SDL call. This is better than crashing on an * unrecoverable allocator fail. */ static void ensureMappingListLock(void) { @@ -301,6 +303,8 @@ Window getContainingWindow(Window window, int x, int y) int i, child_x, child_y, child_w, child_h; Window *children = GET_CHILDREN(window); for (i = GET_WINDOW_STRUCT(window)->children.length - 1; i >= 0; i--) { + if (!isWindowEffectivelyViewable(children[i])) + continue; GET_WINDOW_POS(children[i], child_x, child_y); GET_WINDOW_DIMS(children[i], child_w, child_h); if (x >= child_x && x < child_x + child_w && y >= child_y && @@ -329,12 +333,35 @@ void removeChildFromParent(Window child) void destroyWindow(Display *display, Window window, Bool freeParentData) { + /* Drain pre-cascade stale events for this window FIRST, before any + * recursion. A focused descendant whose revert lands on us would + * otherwise queue FocusIn(window) during the recursion, and a + * naive discard at the end of this function would erase it. The + * order is: drain own stale events -> recurse (children's reverts + * may queue events for us, all survive) -> own revert -> teardown + * -> DestroyNotify. */ + discardQueuedEventsForWindow(display, window); + size_t i; WindowStruct *windowStruct = GET_WINDOW_STRUCT(window); Window *children = GET_CHILDREN(window); for (i = 0; i < windowStruct->children.length; i++) { destroyWindow(display, children[i], False); } + + /* Auto-revert per Xlib spec; post-order recursion means each + * ancestor's check sees the updated focus as the cascade unwinds. */ + revertKeyboardFocusForDestroyedWindow(display, window); + + /* Drop any passive button grabs on this window so a later XID + * reuse cannot route events through a stale grab entry. */ + releaseButtonGrabsForWindow(window); + /* Clear cached pointer-target XIDs so a queued SDL motion event + * does not drive postPointerCrossingEvents -> buildWindowPathToRoot + * into this window's freed WindowStruct. ASan caught this on the + * test-xtest path where XDestroyWindow ran before the SDL motion + * queue drained. */ + clearPointerStateForWindow(window); freeArray(&windowStruct->children); XFreeColormap(display, GET_COLORMAP(window)); for (i = 0; i < windowStruct->properties.length; i++) { @@ -361,9 +388,9 @@ void destroyWindow(Display *display, Window window, Bool freeParentData) SDL_DestroyTexture(windowStruct->sdlTexture); } if (windowStruct->sdlWindow) { + replayTargetForgetWindow(SDL_GetWindowID(windowStruct->sdlWindow)); SDL_DestroyWindow(windowStruct->sdlWindow); } - discardQueuedEventsForWindow(display, window); deleteWindowMapping(window); postEvent(display, window, DestroyNotify); if (freeParentData) { @@ -419,6 +446,21 @@ Bool isParent(Window window1, Window window2) return False; } +Bool isWindowEffectivelyViewable(Window window) +{ + if (window == None || !IS_TYPE(window, WINDOW)) + return False; + if (GET_WINDOW_STRUCT(window)->mapState == UnMapped) + return False; + Window parent = GET_PARENT(window); + while (parent != None) { + if (GET_WINDOW_STRUCT(parent)->mapState == UnMapped) + return False; + parent = GET_PARENT(parent); + } + return True; +} + WindowProperty *findProperty(Array *properties, Atom property, size_t *index) { size_t i; @@ -461,7 +503,7 @@ Bool moveChildToIndex(Window window, size_t targetIndex) return True; } -/* Returns True when the geometric rectangles of `a` and `b` overlap. +/* Returns True when the geometric rectangles of a and b overlap. * Endpoints are computed in int64_t so x + w cannot overflow when the * window struct holds extreme coordinates or dimensions. */ @@ -478,8 +520,8 @@ Bool windowsOverlap(Window a, Window b) } /* TopIf/BottomIf/Opposite are conditional restacks. "Occludes" means an - * upper sibling overlaps `window`; "occluded by" means a lower sibling - * overlaps `window`. An unconditional raise/lower would violate the + * upper sibling overlaps window; "occluded by" means a lower sibling + * overlaps window. An unconditional raise/lower would violate the * Xlib spec when no overlap is present. */ static Bool hasOccludingSiblingAbove(Array *children, size_t index) @@ -577,6 +619,8 @@ static void postParentExposureForOldArea(Display *display, Window parent = GET_PARENT(window); if (parent == None || GET_WINDOW_STRUCT(parent)->mapState == UnMapped) return; + XClearArea(display, parent, oldX, oldY, (unsigned int) oldWidth, + (unsigned int) oldHeight, False); SDL_Rect exposed = {oldX, oldY, oldWidth, oldHeight}; postExposeEvent(display, parent, &exposed, 1); } @@ -621,6 +665,19 @@ static void postResizeExpose(Display *display, int oldHeight) { WindowStruct *windowStruct = GET_WINDOW_STRUCT(window); + if (!IS_MAPPED_TOP_LEVEL_WINDOW(window)) { + SDL_Rect fullWindow = { + 0, + 0, + (int) windowStruct->w, + (int) windowStruct->h, + }; + XClearArea(display, window, 0, 0, (unsigned int) fullWindow.w, + (unsigned int) fullWindow.h, False); + postExposeEvent(display, window, &fullWindow, 1); + return; + } + SDL_Rect rects[2]; size_t count = 0; if ((int) windowStruct->w > oldWidth) { @@ -661,8 +718,6 @@ void resizeWindowTexture(Window window) * (resize events may be faked in tests), so we cannot re-query via * SDL_GetWindowSize here. */ SDL_Texture *oldTexture = windowStruct->sdlTexture; - SDL_Rect destRect = {0, 0, 0, 0}; - SDL_QueryTexture(oldTexture, NULL, NULL, &destRect.w, &destRect.h); SDL_Texture *newTexture = SDL_CreateTexture( windowRenderer, SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, (int) windowStruct->w, (int) windowStruct->h); @@ -682,7 +737,6 @@ void resizeWindowTexture(Window window) GET_GREEN_FROM_COLOR(bg), GET_BLUE_FROM_COLOR(bg), GET_ALPHA_FROM_COLOR(bg)); SDL_RenderClear(windowRenderer); - SDL_RenderCopy(windowRenderer, oldTexture, NULL, &destRect); windowStruct->sdlTexture = newTexture; windowStruct->needsPresent = True; SDL_SetRenderTarget(windowRenderer, @@ -729,6 +783,18 @@ void mapRequestedChildren(Display *display, Window window) continue; if (GET_WINDOW_STRUCT(children[i])->mapState == Mapped) { + WindowStruct *childStruct = GET_WINDOW_STRUCT(children[i]); + if (childStruct->sdlTexture) { + if (!mergeWindowDrawables(window, children[i])) { + LOG("Failed to merge mapped child drawables in %s\n", + __func__); + continue; + } + } + childStruct->contentsMergedToParent = False; + XClearArea(display, children[i], 0, 0, 0, 0, False); + SDL_Rect exposeRect = {0, 0, childStruct->w, childStruct->h}; + postExposeEvent(display, children[i], &exposeRect, 1); mapRequestedChildren(display, children[i]); } else if (GET_WINDOW_STRUCT(children[i])->mapState == MapRequested) { if (!mergeWindowDrawables(window, children[i])) { @@ -826,7 +892,7 @@ Bool configureWindow(Display *display, } windowStruct->w = (unsigned int) width; windowStruct->h = (unsigned int) height; - if (isMappedTopLevelWindow) { + if (windowStruct->sdlTexture) { resizeWindowTexture(window); } hasChanged = True; diff --git a/src/window-internal.h b/src/window-internal.h index 4a4177e..6e7e304 100644 --- a/src/window-internal.h +++ b/src/window-internal.h @@ -33,6 +33,7 @@ void resizeWindowTexture(Window window); void deleteWindowMapping(Window window); void registerWindowMapping(Window window, Uint32 sdlWindowId); Bool isParent(Window window1, Window window2); +Bool isWindowEffectivelyViewable(Window window); WindowProperty *findProperty(Array *properties, Atom property, size_t *index); void freeWindowProperty(WindowProperty *property); Bool mergeWindowDrawables(Window parent, Window child); diff --git a/src/window.c b/src/window.c index b6626ce..c1c1119 100644 --- a/src/window.c +++ b/src/window.c @@ -11,6 +11,8 @@ #include "image.h" #include "input.h" #include "net-atoms.h" +#include "replay.h" +#include "replay-target.h" #include "visual.h" #include "window.h" @@ -70,7 +72,12 @@ static Bool realizeTopLevelWindow(Display *display, Window window) if (windowStruct->sdlWindow) return True; - Uint32 flags = SDL_WINDOW_SHOWN; + /* Start hidden so XMapWindow controls the show timing. Without + * this, SDL_CreateWindow flashes an empty window before + * drawWindowDataToScreen renders content; the SDL_ShowWindow call + * at the end of XMapWindow brings it on screen with the first + * frame already drawn. */ + Uint32 flags = SDL_WINDOW_HIDDEN; if (windowStruct->borderWidth == 0) { flags |= SDL_WINDOW_BORDERLESS; } @@ -108,6 +115,9 @@ static Bool realizeTopLevelWindow(Display *display, Window window) if (windowStruct->icon) { SDL_SetWindowIcon(windowStruct->sdlWindow, windowStruct->icon); } + /* Prime the X backing store while the native window is still hidden. + * XMapWindow will present and show it after map/expose state is ready. */ + (void) getWindowRenderer(window); return True; } @@ -117,6 +127,11 @@ static void unrealizeTopLevelWindow(Window window) if (!windowStruct->sdlWindow) return; + /* If this top-level was the replay/XTest injection target, retire the + * cached ID so the next mapped shell can take its place. */ + Uint32 destroyedId = SDL_GetWindowID(windowStruct->sdlWindow); + replayTargetForgetWindow(destroyedId); + deleteWindowMapping(window); SDL_DestroyWindow(windowStruct->sdlWindow); windowStruct->sdlWindow = NULL; @@ -404,7 +419,6 @@ int XMapWindow(Display *display, Window window) * stay in the terminal. Real X11's MapWindow puts the window on screen; * raising it here matches user expectation. */ - SDL_RaiseWindow(windowStruct->sdlWindow); if (windowStruct->windowName) { free(windowStruct->windowName); windowStruct->windowName = NULL; @@ -418,6 +432,18 @@ int XMapWindow(Display *display, Window window) * popup shells from finishing their map sequence. */ SDL_StopTextInput(); } + /* First top-level mapping is the canonical "window is up" signal + * for an external test script. Snapshot the SDL window ID now, + * on the main thread, so XTest's off-thread injection has a + * stable target without scanning the live window tree. Then + * arm the replay engine. */ + if (windowStruct->sdlWindow) { + int wid = 0, hgt = 0; + SDL_GetWindowSize(windowStruct->sdlWindow, &wid, &hgt); + replayTargetOfferWindow(SDL_GetWindowID(windowStruct->sdlWindow), + windowStruct->x, windowStruct->y, wid, hgt); + } + replayStartIfRequested(display); } else { /* Mapping a window that is not a top level window */ Window parent = GET_PARENT(window); if (GET_WINDOW_STRUCT(parent)->mapState == Mapped) { @@ -442,7 +468,14 @@ int XMapWindow(Display *display, Window window) WindowStruct *windowStruct = GET_WINDOW_STRUCT(window); SDL_Rect exposeRect = {0, 0, windowStruct->w, windowStruct->h}; postExposeEvent(display, window, &exposeRect, 1); - drawWindowDataToScreen(); + if (windowStruct->sdlWindow) { + /* Render first into the hidden SDL_Window so the user never + * sees an empty frame, then show + raise. */ + windowStruct->needsPresent = True; + drawWindowDataToScreen(); + SDL_ShowWindow(windowStruct->sdlWindow); + SDL_RaiseWindow(windowStruct->sdlWindow); + } // SDL_UpdateWindowSurface(GET_WINDOW_STRUCT(window)->sdlWindow); @@ -468,17 +501,14 @@ int XUnmapWindow(Display *display, Window window) windowStruct->mapState = UnMapped; postVisibilityForWindowAndSiblings(display, window); if (windowStruct->sdlWindow) { - SDL_Window *sdlWindow = windowStruct->sdlWindow; SDL_Renderer *sdlRenderer = windowStruct->sdlRenderer; - windowStruct->sdlWindow = NULL; windowStruct->sdlRenderer = NULL; - windowStruct->needsPresent = False; if (sdlRenderer) { invalidatePutImageStagingTexture(sdlRenderer); invalidateTextCacheForRenderer(sdlRenderer); SDL_DestroyRenderer(sdlRenderer); } - SDL_DestroyWindow(sdlWindow); + unrealizeTopLevelWindow(window); } else if (GET_WINDOW_STRUCT(GET_PARENT(window))->mapState != UnMapped) { postEvent(display, window, UnmapNotify, False); SDL_Rect exposeRect = {windowStruct->x, windowStruct->y, @@ -657,6 +687,17 @@ int XReparentWindow(Display *display, windowStruct->mapState = mapState; return 0; } + /* realizeTopLevelWindow creates the SDL_Window hidden so + * XMapWindow can control show timing. The reparent path + * doesn't go through XMapWindow, so the window stays + * hidden, and XGetWindowAttributes reports IsUnmapped + * even though mapState is Mapped. Show it now since the + * old child was already mapped before reparent. */ + if (windowStruct->sdlWindow) { + windowStruct->needsPresent = True; + drawWindowDataToScreen(); + SDL_ShowWindow(windowStruct->sdlWindow); + } } } if (mapState != UnMapped) { @@ -723,16 +764,17 @@ Bool XTranslateCoordinates(Display *display, *destinationYReturn = currY; if (childReturn) { *childReturn = None; - // Get the first child which contains x and y Window *children = GET_CHILDREN(destinationWindow); - size_t i; - for (i = 0; i < GET_WINDOW_STRUCT(destinationWindow)->children.length; - i++) { - GET_WINDOW_POS(children[i], x, y); - GET_WINDOW_DIMS(children[i], width, height); + for (size_t i = GET_WINDOW_STRUCT(destinationWindow)->children.length; + i > 0; i--) { + Window child = children[i - 1]; + if (!isWindowEffectivelyViewable(child)) + continue; + GET_WINDOW_POS(child, x, y); + GET_WINDOW_DIMS(child, width, height); if (x <= currX && x + width > currX && y <= currY && y + height > currY) { - *childReturn = children[i]; + *childReturn = child; break; } } diff --git a/src/wrapper/sdl-wrapper.c b/src/wrapper/sdl-wrapper.c index b43dee4..c1960f0 100644 --- a/src/wrapper/sdl-wrapper.c +++ b/src/wrapper/sdl-wrapper.c @@ -136,7 +136,12 @@ SDL_WRAP(SDL_PixelFormat *, SDL_AllocFormat, (Uint32 pixel_format), (pixel_format)) +SDL_WRAP(int, SDL_AtomicAdd, (SDL_atomic_t * a, int v), (a, v)) SDL_WRAP(int, SDL_AtomicGet, (SDL_atomic_t * a), (a)) +SDL_WRAP(SDL_bool, + SDL_AtomicCAS, + (SDL_atomic_t * a, int oldv, int newv), + (a, oldv, newv)) SDL_WRAP_VOID(SDL_AtomicLock, (SDL_SpinLock * lock), (lock)) SDL_WRAP(int, SDL_AtomicSet, (SDL_atomic_t * a, int v), (a, v)) SDL_WRAP_VOID(SDL_AtomicUnlock, (SDL_SpinLock * lock), (lock)) @@ -214,6 +219,7 @@ SDL_WRAP(int, SDL_GetCurrentDisplayMode, (int displayIndex, SDL_DisplayMode *mode), (displayIndex, mode)) +SDL_WRAP(const char *, SDL_GetCurrentVideoDriver, (void), ()) SDL_WRAP(SDL_Cursor *, SDL_GetCursor, (void), ()) SDL_WRAP(SDL_Cursor *, SDL_GetDefaultCursor, (void), ()) SDL_WRAP(int, @@ -226,10 +232,13 @@ SDL_WRAP(SDL_bool, (SDL_EventFilter * filter, void **userdata), (filter, userdata)) SDL_WRAP(Uint32, SDL_GetGlobalMouseState, (int *x, int *y), (x, y)) +SDL_WRAP(SDL_Keycode, SDL_GetKeyFromScancode, (SDL_Scancode s), (s)) +SDL_WRAP(SDL_Scancode, SDL_GetScancodeFromKey, (SDL_Keycode k), (k)) SDL_WRAP(SDL_Keymod, SDL_GetModState, (void), ()) SDL_WRAP(SDL_Window *, SDL_GetMouseFocus, (void), ()) SDL_WRAP(Uint32, SDL_GetMouseState, (int *x, int *y), (x, y)) SDL_WRAP(int, SDL_GetNumVideoDisplays, (void), ()) +SDL_WRAP(Uint32, SDL_GetTicks, (void), ()) SDL_WRAP_VOID( SDL_GetRGB, (Uint32 pixel, const SDL_PixelFormat *format, Uint8 *r, Uint8 *g, Uint8 *b), @@ -263,6 +272,7 @@ SDL_WRAP_VOID(SDL_GetWindowSize, SDL_WRAP(SDL_Surface *, SDL_GetWindowSurface, (SDL_Window * window), (window)) SDL_WRAP(const char *, SDL_GetWindowTitle, (SDL_Window * window), (window)) SDL_WRAP(SDL_bool, SDL_HasClipboardText, (void), ()) +SDL_WRAP(SDL_bool, SDL_HasEvent, (Uint32 type), (type)) SDL_WRAP(int, SDL_Init, (Uint32 flags), (flags)) SDL_WRAP(SDL_bool, SDL_IntersectRect, @@ -300,7 +310,16 @@ SDL_WRAP(int, (texture, format, access, w, h)) SDL_WRAP_VOID(SDL_Quit, (void), ()) SDL_WRAP_VOID(SDL_RaiseWindow, (SDL_Window * window), (window)) +SDL_WRAP(SDL_RWops *, + SDL_RWFromFile, + (const char *file, const char *mode), + (file, mode)) +SDL_WRAP(int, + SDL_SaveBMP_RW, + (SDL_Surface * surface, SDL_RWops *dst, int freedst), + (surface, dst, freedst)) SDL_WRAP(Uint32, SDL_RegisterEvents, (int numevents), (numevents)) +SDL_WRAP(SDL_bool, SDL_RemoveTimer, (SDL_TimerID id), (id)) SDL_WRAP(int, SDL_RenderClear, (SDL_Renderer * renderer), (renderer)) SDL_WRAP(int, SDL_RenderCopy, @@ -413,6 +432,7 @@ SDL_WRAP_VOID(SDL_SetWindowSize, SDL_WRAP_VOID(SDL_SetWindowTitle, (SDL_Window * window, const char *title), (window, title)) +SDL_WRAP_VOID(SDL_ShowWindow, (SDL_Window * window), (window)) SDL_WRAP_VOID(SDL_StopTextInput, (void), ()) SDL_WRAP(SDL_threadID, SDL_ThreadID, (void), ()) SDL_WRAP(int, SDL_UnlockMutex, (SDL_mutex * mutex), (mutex)) @@ -434,6 +454,9 @@ SDL_WRAP(int, (src, srcrect, dst, dstrect)) SDL_WRAP(int, SDL_WaitEvent, (SDL_Event * event), (event)) SDL_WRAP(int, SDL_WarpMouseGlobal, (int x, int y), (x, y)) +SDL_WRAP_VOID(SDL_WarpMouseInWindow, + (SDL_Window * window, int x, int y), + (window, x, y)) SDL_WRAP(Uint32, SDL_WasInit, (Uint32 flags), (flags)) SDL_WRAP_VOID(SDL_free, (void *mem), (mem)) SDL_WRAP(void *, SDL_memset, (void *dst, int c, size_t len), (dst, c, len)) diff --git a/src/xrender.c b/src/xrender.c index cc7bb3d..670ff9d 100644 --- a/src/xrender.c +++ b/src/xrender.c @@ -122,6 +122,15 @@ void XRenderFillRectangles(Display *dpy, (void) n_rects; } +void XRenderSetPictureTransform(Display *dpy, + Picture picture, + XTransform *transform) +{ + (void) dpy; + (void) picture; + (void) transform; +} + GlyphSet XRenderCreateGlyphSet(Display *dpy, _Xconst XRenderPictFormat *format) { (void) dpy; diff --git a/src/xtest.c b/src/xtest.c new file mode 100644 index 0000000..7fcbbf2 --- /dev/null +++ b/src/xtest.c @@ -0,0 +1,335 @@ +/* XTest extension implementation for libx11-compat. + * + * Real X11 implements XTest in the server: a test client calls + * XTestFakeXxxEvent and the server distributes the resulting events to every + * connected client. libx11-compat is single-process, so there is no server and + * no IPC; fake events here are injected directly into the calling process's SDL + * event queue via SDL_PushEvent. The synthesized SDL_Event then flows through + * the same onSdlEvent filter and convertEvent() path that real user input + * takes, so resulting ButtonPress/KeyPress/MotionNotify is indistinguishable + * from a real one downstream. + * + * This is what overcomes the macOS NSEvent-injection limitation: the synthetic + * SDL_Event lives inside our process and never has to cross the NSWindow/AppKit + * responder chain that drops external CGEvent button presses against + * non-keyWindow processes. + */ + +#include +#include +#include +#include +#include +#include +#include "extension.h" +#include "window.h" +#include "events.h" +#include "replay-target.h" +#include "util.h" + +#define XTEST_MAJOR 2 +#define XTEST_MINOR 2 + +/* The XTest spec says delay is "in milliseconds before delivering the event"; + * servers honor it because they have an internal scheduler. We just sleep when + * nonzero. Callers chain fakes with delay=0, delay=10, delay=10... to pace a + * sequence. + */ +static void honorDelay(unsigned long delay_ms) +{ + if (delay_ms > 0) + SDL_Delay((Uint32) delay_ms); +} + +static int pushFakeEvent(Display *display, SDL_Event *event) +{ + if (SDL_PushEvent(event) != 1) + return 0; + wakeEventPipeForExternalEvent(display); + return 1; +} + +Bool XTestQueryExtension(Display *display, + int *event_basep, + int *error_basep, + int *majorp, + int *minorp) +{ + if (!display) + return False; + int opcode = 0; + int eventBase = 0; + int errorBase = 0; + if (!XQueryExtension(display, XTestExtensionName, &opcode, &eventBase, + &errorBase)) + return False; + if (event_basep) + *event_basep = eventBase; + if (error_basep) + *error_basep = errorBase; + if (majorp) + *majorp = XTEST_MAJOR; + if (minorp) + *minorp = XTEST_MINOR; + return True; +} + +int XTestFakeMotionEvent(Display *display, + int screen_number, + int x, + int y, + unsigned long delay) +{ + (void) screen_number; + honorDelay(delay); + /* Coherent snapshot of (id, root) so a retarget between reading the + * id and the root cannot send this event to one window with another + * window's local coordinates. */ + Uint32 winId = 0; + int localX = 0, localY = 0; + if (!replayTargetTranslateRoot(x, y, &winId, &localX, &localY)) + return 0; + replayTargetRememberPointer(localX, localY); + SDL_Event ev; + SDL_zero(ev); + ev.type = SDL_MOUSEMOTION; + ev.motion.timestamp = SDL_GetTicks(); + ev.motion.windowID = winId; + ev.motion.x = localX; + ev.motion.y = localY; + return pushFakeEvent(display, &ev); +} + +int XTestFakeRelativeMotionEvent(Display *display, + int x, + int y, + unsigned long delay) +{ + honorDelay(delay); + Uint32 winId = replayTargetWindowId(); + if (winId == 0) + return 0; + int prevX = 0, prevY = 0; + if (!replayTargetReadPointer(&prevX, &prevY)) + SDL_GetMouseState(&prevX, &prevY); + int newX = prevX + x; + int newY = prevY + y; + replayTargetRememberPointer(newX, newY); + SDL_Event ev; + SDL_zero(ev); + ev.type = SDL_MOUSEMOTION; + ev.motion.timestamp = SDL_GetTicks(); + ev.motion.windowID = winId; + ev.motion.x = newX; + ev.motion.y = newY; + ev.motion.xrel = x; + ev.motion.yrel = y; + return pushFakeEvent(display, &ev); +} + +int XTestFakeButtonEvent(Display *display, + unsigned int button, + Bool is_press, + unsigned long delay) +{ + honorDelay(delay); + Uint32 winId = replayTargetWindowId(); + if (winId == 0) + return 0; + int sdlButton; + switch (button) { + case Button1: + sdlButton = SDL_BUTTON_LEFT; + break; + case Button2: + sdlButton = SDL_BUTTON_MIDDLE; + break; + case Button3: + sdlButton = SDL_BUTTON_RIGHT; + break; + case 4: + case 5: { + /* X11 buttons 4/5 are scroll wheel up/down. SDL models scrolling as + * SDL_MOUSEWHEEL, not button events. Synthesize one wheel tick with the + * appropriate y direction on press; the X-protocol release that + * follows real wheel events has no SDL analog, so swallow it. + */ + if (!is_press) + return 1; + SDL_Event ev; + SDL_zero(ev); + ev.type = SDL_MOUSEWHEEL; + ev.wheel.timestamp = SDL_GetTicks(); + ev.wheel.windowID = winId; + ev.wheel.y = (button == 4) ? 1 : -1; + ev.wheel.direction = SDL_MOUSEWHEEL_NORMAL; + /* Tag the event as synthetic so convertEvent's SDL_MOUSEWHEEL + * handler knows to use the injected pointer position instead of + * SDL_GetMouseState. SDL_TOUCH_MOUSEID is the documented sentinel + * for "not a real mouse instance" and never collides with a + * device-driven which value. Without this tag, a real + * interactive wheel scroll AFTER any XTest activity would also + * route through the injected coordinates because we cannot tell + * the events apart from convertEvent. */ + ev.wheel.which = SDL_TOUCH_MOUSEID; + return pushFakeEvent(display, &ev); + } + default: + return 0; + } + + int curX = 0, curY = 0; + if (!replayTargetReadPointer(&curX, &curY)) + SDL_GetMouseState(&curX, &curY); + SDL_Event ev; + SDL_zero(ev); + ev.type = is_press ? SDL_MOUSEBUTTONDOWN : SDL_MOUSEBUTTONUP; + ev.button.timestamp = SDL_GetTicks(); + ev.button.windowID = winId; + ev.button.button = (Uint8) sdlButton; + ev.button.state = is_press ? SDL_PRESSED : SDL_RELEASED; + ev.button.clicks = 1; + ev.button.x = curX; + ev.button.y = curY; + return pushFakeEvent(display, &ev); +} + +int XTestFakeKeyEvent(Display *display, + unsigned int keycode, + Bool is_press, + unsigned long delay) +{ + honorDelay(delay); + Uint32 winId = replayTargetWindowId(); + if (winId == 0) + return 0; + SDL_Event ev; + SDL_zero(ev); + ev.type = is_press ? SDL_KEYDOWN : SDL_KEYUP; + ev.key.timestamp = SDL_GetTicks(); + ev.key.windowID = winId; + ev.key.state = is_press ? SDL_PRESSED : SDL_RELEASED; + /* X keycodes are server-defined; SDL scancodes are SDL's own enum and + * the convertEvent path derives the X keycode back from keysym.sym + * (low byte). Pass the requested code through as the SDL_Keycode so + * the round-trip lands on the same X keycode the caller asked for, + * and let SDL_GetScancodeFromKey fill in the scancode for callers + * that consume it. Callers wanting a specific keysym should + * XStringToKeysym first. + */ + ev.key.keysym.sym = (SDL_Keycode) keycode; + ev.key.keysym.scancode = SDL_GetScancodeFromKey((SDL_Keycode) keycode); + return pushFakeEvent(display, &ev); +} + +/* Stubs: device-extension variants need XInput plumbing libx11-compat doesn't + * model. Return success; the caller's chain typically falls back to non-device + * XTest paths anyway. + */ +int XTestFakeDeviceKeyEvent(Display *display, + XDevice *dev, + unsigned int keycode, + Bool is_press, + int *axes, + int n_axes, + unsigned long delay) +{ + (void) dev; + (void) axes; + (void) n_axes; + return XTestFakeKeyEvent(display, keycode, is_press, delay); +} + +int XTestFakeDeviceButtonEvent(Display *display, + XDevice *dev, + unsigned int button, + Bool is_press, + int *axes, + int n_axes, + unsigned long delay) +{ + (void) dev; + (void) axes; + (void) n_axes; + return XTestFakeButtonEvent(display, button, is_press, delay); +} + +int XTestFakeProximityEvent(Display *display, + XDevice *dev, + Bool in_prox, + int *axes, + int n_axes, + unsigned long delay) +{ + (void) display; + (void) dev; + (void) in_prox; + (void) axes; + (void) n_axes; + honorDelay(delay); + return 1; +} + +int XTestFakeDeviceMotionEvent(Display *display, + XDevice *dev, + Bool is_relative, + int first_axis, + int *axes, + int n_axes, + unsigned long delay) +{ + (void) dev; + (void) first_axis; + if (n_axes < 2 || !axes) + return 0; + if (is_relative) + return XTestFakeRelativeMotionEvent(display, axes[0], axes[1], delay); + return XTestFakeMotionEvent(display, 0, axes[0], axes[1], delay); +} + +Bool XTestCompareCursorWithWindow(Display *display, + Window window, + Cursor cursor) +{ + (void) display; + (void) window; + (void) cursor; + /* No cursor introspection; reporting "same" prevents test programs from + * looping forever waiting for a cursor change we can't observe. + */ + return True; +} + +Bool XTestCompareCurrentCursorWithWindow(Display *display, Window window) +{ + (void) display; + (void) window; + return True; +} + +int XTestGrabControl(Display *display, Bool impervious) +{ + (void) display; + (void) impervious; + /* No grab semantics: events flow directly to widgets. */ + return 1; +} + +void XTestSetGContextOfGC(GC gc, GContext gid) +{ + if (gc) + gc->gid = gid; +} + +void XTestSetVisualIDOfVisual(Visual *visual, VisualID visualid) +{ + if (visual) + visual->visualid = visualid; +} + +Status XTestDiscard(Display *display) +{ + (void) display; + /* No buffered fake-event records to flush. */ + return 1; +} diff --git a/tests/api-symbols.txt b/tests/api-symbols.txt index be20b02..f7cf304 100644 --- a/tests/api-symbols.txt +++ b/tests/api-symbols.txt @@ -177,6 +177,7 @@ XFreePixmap XFreeStringList XFreeThreads XGContextFromGC +XGeometry XGetAtomName XGetAtomNames XGetClassHint @@ -337,6 +338,7 @@ XRenderFindVisualFormat XRenderFreePicture XRenderQueryExtension XRenderQueryVersion +XRenderSetPictureTransform XReparentWindow XResetScreenSaver XResizeWindow @@ -472,6 +474,21 @@ XSyncSetCounter XSyncSetPriority XSyncTriggerFence XSynchronize +XTestCompareCurrentCursorWithWindow +XTestCompareCursorWithWindow +XTestDiscard +XTestFakeButtonEvent +XTestFakeDeviceButtonEvent +XTestFakeDeviceKeyEvent +XTestFakeDeviceMotionEvent +XTestFakeKeyEvent +XTestFakeMotionEvent +XTestFakeProximityEvent +XTestFakeRelativeMotionEvent +XTestGrabControl +XTestQueryExtension +XTestSetGContextOfGC +XTestSetVisualIDOfVisual XTextExtents XTextExtents16 XTextPropertyToStringList diff --git a/tests/check.c b/tests/check.c index 2cdc0d5..76ba5e9 100644 --- a/tests/check.c +++ b/tests/check.c @@ -19,6 +19,7 @@ #include "drawing.h" #include "gc.h" #include "image.h" +#include "input.h" #include "path/compose.h" #include "path/edges.h" #include "path/path.h" @@ -73,6 +74,17 @@ static XEvent make_event(int type, Window window) return event; } +static int font_struct_char_width(XFontStruct *font, unsigned int ch) +{ + if (!font) + return 0; + if (font->per_char && ch >= font->min_char_or_byte2 && + ch <= font->max_char_or_byte2) { + return font->per_char[ch - font->min_char_or_byte2].width; + } + return font->min_bounds.width; +} + static int expect_map_state(Display *display, Window window, int expected) { XWindowAttributes attrs; @@ -133,6 +145,7 @@ static int pixel_is_rgba(SDL_Surface *surface, Uint8 green, Uint8 blue, Uint8 alpha); +static int pixel_is_between_black_and_white(SDL_Surface *surface, int x, int y); static int count_open_file_descriptors(void) { @@ -257,6 +270,8 @@ static int test_keyboard(Display *display) CHECK(XStringToKeysym("a") == XK_a, "XStringToKeysym(\"a\") failed"); CHECK(XStringToKeysym("A") == XK_A, "XStringToKeysym(\"A\") failed"); CHECK(XStringToKeysym("1") == XK_1, "XStringToKeysym(\"1\") failed"); + CHECK(XStringToKeysym("KDelete") == XK_Delete, + "XStringToKeysym(\"KDelete\") failed"); unsigned int consumedModifiers = ShiftMask; KeySym lookupSym = NoSymbol; @@ -320,6 +335,7 @@ static int test_keyboard(Display *display) Window probeWindow = XCreateSimpleWindow( display, DefaultRootWindow(display), 0, 0, 10, 10, 0, 0, 0); CHECK(probeWindow != None, "XCreateSimpleWindow for focus probe failed"); + XSelectInput(display, probeWindow, FocusChangeMask); XSetInputFocus(display, probeWindow, RevertToParent, CurrentTime); Window focused = None; int revert = 0; @@ -327,10 +343,148 @@ static int test_keyboard(Display *display) CHECK(focused == probeWindow, "XSetInputFocus did not update keyboard focus"); CHECK(revert == RevertToParent, "XSetInputFocus did not record revert_to"); + XEvent focusEvent; + CHECK(XCheckTypedWindowEvent(display, probeWindow, FocusIn, &focusEvent), + "XSetInputFocus did not queue FocusIn"); + Window focusChild = + XCreateSimpleWindow(display, probeWindow, 1, 1, 5, 5, 0, 0, 0); + CHECK(focusChild != None, "XCreateSimpleWindow for focus child failed"); + XSelectInput(display, focusChild, FocusChangeMask); + XSetInputFocus(display, focusChild, RevertToParent, CurrentTime); + CHECK(XCheckTypedWindowEvent(display, probeWindow, FocusOut, &focusEvent), + "parent-to-child focus change did not queue parent FocusOut"); + CHECK(focusEvent.xfocus.detail == NotifyInferior, + "parent FocusOut to focused child did not use NotifyInferior"); + CHECK(XCheckTypedWindowEvent(display, focusChild, FocusIn, &focusEvent), + "parent-to-child focus change did not queue child FocusIn"); + CHECK(focusEvent.xfocus.detail == NotifyAncestor, + "child FocusIn from parent did not use NotifyAncestor"); XSetInputFocus(display, None, RevertToParent, CurrentTime); XGetInputFocus(display, &focused, &revert); + /* Per Xlib spec XGetInputFocus reports the actual target: None for + * None, PointerRoot for PointerRoot. The previous behavior collapsed + * both into PointerRoot, which was non-conformant. */ + CHECK(focused == None, + "XGetInputFocus after None focus should report None"); + /* Window-to-None per Xlib 10.7.1: the relationship between a window + * and a non-window focus target is nonlinear (no shared hierarchy), + * so the leaf gets NotifyNonlinear and each strict ancestor below + * the root gets NotifyNonlinearVirtual. The root also receives a + * companion FocusIn with detail NotifyDetailNone, not asserted here + * because the root has no FocusChangeMask. */ + CHECK(XCheckTypedWindowEvent(display, focusChild, FocusOut, &focusEvent), + "XSetInputFocus(None) did not queue FocusOut on focus leaf"); + CHECK(focusEvent.xfocus.detail == NotifyNonlinear, + "FocusOut to None on focus leaf did not use NotifyNonlinear"); + CHECK(XCheckTypedWindowEvent(display, probeWindow, FocusOut, &focusEvent), + "XSetInputFocus(None) did not queue FocusOut on focus ancestor"); + CHECK(focusEvent.xfocus.detail == NotifyNonlinearVirtual, + "FocusOut to None on focus ancestor did not use " + "NotifyNonlinearVirtual"); + /* Round-trip the PointerRoot case so a future regression that + * re-collapses the two non-window targets in XGetInputFocus is + * caught at this test. */ + XSetInputFocus(display, (Window) PointerRoot, RevertToParent, CurrentTime); + XGetInputFocus(display, &focused, &revert); CHECK(focused == (Window) PointerRoot, - "XGetInputFocus after None focus should report PointerRoot"); + "XGetInputFocus after PointerRoot focus should report PointerRoot"); + + /* Destroying the focus window must auto-revert per the recorded + * revert_to (Xlib spec). Without this the focus state stayed pinned + * to a freed XID and subsequent key events routed to dead memory. + * The parent must be mapped: RevertToParent walks past unviewable + * ancestors, which is what a real X server does too. */ + Window revertParent = XCreateSimpleWindow( + display, DefaultRootWindow(display), 0, 0, 8, 8, 0, 0, 0); + CHECK(revertParent != None, "XCreateSimpleWindow for revert parent failed"); + XMapWindow(display, revertParent); + Window revertChild = + XCreateSimpleWindow(display, revertParent, 0, 0, 4, 4, 0, 0, 0); + CHECK(revertChild != None, "XCreateSimpleWindow for revert child failed"); + XMapWindow(display, revertChild); + XSetInputFocus(display, revertChild, RevertToParent, CurrentTime); + XDestroyWindow(display, revertChild); + XGetInputFocus(display, &focused, &revert); + CHECK(focused == revertParent, + "destroying focus window with RevertToParent did not revert to " + "parent"); + CHECK(revert == RevertToNone, + "post-revert revert_to should reset to RevertToNone per spec"); + XSetInputFocus(display, revertParent, RevertToPointerRoot, CurrentTime); + XDestroyWindow(display, revertParent); + XGetInputFocus(display, &focused, &revert); + CHECK(focused == (Window) PointerRoot, + "destroying focus window with RevertToPointerRoot did not revert " + "to PointerRoot"); + + /* RevertToParent with an unmapped parent walks past it to the next + * viewable ancestor (per spec's "closest viewable ancestor" clause). + * Setup note: strict Xlib would BadMatch the XSetInputFocus call + * below because mappedChild is not viewable (its parent is + * unmapped). The compat layer does not enforce that check by + * design - Motif sets focus on widgets before they are fully + * realized and depends on the call landing. This regression is for + * the compat-layer revert-walk behavior, not for a spec-conformant + * client scenario. */ + Window unmappedParent = XCreateSimpleWindow( + display, DefaultRootWindow(display), 0, 0, 8, 8, 0, 0, 0); + CHECK(unmappedParent != None, + "XCreateSimpleWindow for unmapped revert parent failed"); + Window mappedChild = + XCreateSimpleWindow(display, unmappedParent, 0, 0, 4, 4, 0, 0, 0); + CHECK(mappedChild != None, + "XCreateSimpleWindow for unmapped-parent child failed"); + XMapWindow(display, mappedChild); + XSetInputFocus(display, mappedChild, RevertToParent, CurrentTime); + XDestroyWindow(display, mappedChild); + XGetInputFocus(display, &focused, &revert); + CHECK(focused != unmappedParent, + "RevertToParent landed on an unmapped ancestor instead of " + "walking up"); + XDestroyWindow(display, unmappedParent); + + /* Cascading destroy must preserve the FocusIn(parent) generated by + * a child's revert. Earlier code drained the parent's queue after + * recursion (which discarded that just-queued event); the discard + * now runs before recursion. Verify by selecting FocusChangeMask on + * the parent, focusing a mapped child with RevertToParent, then + * destroying the parent (which cascades to the child). The + * intermediate FocusIn(parent, NotifyInferior) from the child's + * revert (child is descendant of parent, so the spec detail is + * NotifyInferior, not NotifyAncestor) must reach the queue. */ + Window cascadeParent = XCreateSimpleWindow( + display, DefaultRootWindow(display), 0, 0, 8, 8, 0, 0, 0); + CHECK(cascadeParent != None, + "XCreateSimpleWindow for cascade parent failed"); + XSelectInput(display, cascadeParent, FocusChangeMask); + XMapWindow(display, cascadeParent); + Window cascadeChild = + XCreateSimpleWindow(display, cascadeParent, 0, 0, 4, 4, 0, 0, 0); + CHECK(cascadeChild != None, "XCreateSimpleWindow for cascade child failed"); + XMapWindow(display, cascadeChild); + XSetInputFocus(display, cascadeChild, RevertToParent, CurrentTime); + while ( + XCheckTypedWindowEvent(display, cascadeParent, FocusIn, &focusEvent)) { + } + while ( + XCheckTypedWindowEvent(display, cascadeParent, FocusOut, &focusEvent)) { + } + XDestroyWindow(display, cascadeParent); + Bool sawRevertFocusIn = False; + while ( + XCheckTypedWindowEvent(display, cascadeParent, FocusIn, &focusEvent)) { + if (focusEvent.xfocus.detail == NotifyInferior) + sawRevertFocusIn = True; + } + CHECK(sawRevertFocusIn, + "cascading destroy discarded the child-revert FocusIn on the " + "parent"); + /* Drain any other events the cascade emitted (FocusOut on the parent + * from its own auto-revert, DestroyNotify, etc.) so they do not bleed + * into later subtests that assert XNextEvent ordering. */ + while (XPending(display) > 0) + XNextEvent(display, &focusEvent); + XDestroyWindow(display, probeWindow); XSetInputFocus(display, focusedBefore, revertBefore, CurrentTime); @@ -365,6 +519,21 @@ static int test_gc(Display *display) CHECK(XGContextFromGC(defaultGc) != None, "DefaultGC has no XID"); CHECK(GET_GC(defaultGc)->generation == 0, "DefaultGC generation was not initialized"); + XGCValues defaultValues; + CHECK(XGetGCValues(display, defaultGc, GCForeground | GCBackground, + &defaultValues), + "XGetGCValues failed for DefaultGC"); + CHECK( + defaultValues.foreground == BlackPixel(display, DefaultScreen(display)), + "DefaultGC foreground did not match BlackPixel"); + CHECK( + defaultValues.background == WhitePixel(display, DefaultScreen(display)), + "DefaultGC background did not match WhitePixel"); + CHECK(defaultValues.foreground < + (1ul << DefaultDepth(display, DefaultScreen(display))) && + defaultValues.background < + (1ul << DefaultDepth(display, DefaultScreen(display))), + "DefaultGC pixels exceeded DefaultDepth range"); GC gc = XCreateGC(display, root, 0, NULL); CHECK(gc != NULL, "XCreateGC returned NULL"); @@ -658,6 +827,54 @@ static int test_compat_stubs(Display *display) "XWMGeometry user position wrong"); CHECK(gravity == NorthEastGravity, "XWMGeometry user gravity wrong"); + mask = XGeometry(display, DefaultScreen(display), "80x24", "120x40-0-0", 1, + 8, 16, 4, 6, &wx, &wy, &ww, &wh); + CHECK(mask == (WidthValue | HeightValue), + "XGeometry returned default position bits"); + CHECK(ww == 80 && wh == 24, "XGeometry did not preserve unit width/height"); + CHECK(wx == DisplayWidth(display, DefaultScreen(display)) - (120 * 8 + 4) - + 2 && + wy == DisplayHeight(display, DefaultScreen(display)) - + (40 * 16 + 6) - 2, + "XGeometry did not use default size for default negative position"); + + mask = XGeometry(display, DefaultScreen(display), "80x24+7-9", "120x40+1+2", + 2, 8, 16, 4, 6, &wx, &wy, &ww, &wh); + CHECK(mask == (WidthValue | HeightValue | XValue | YValue | YNegative), + "XGeometry user mask wrong"); + CHECK(ww == 80 && wh == 24, "XGeometry user size was converted to pixels"); + CHECK(wx == 7 && wy == DisplayHeight(display, DefaultScreen(display)) - + (24 * 16 + 6) - 4 - 9, + "XGeometry user position wrong"); + + mask = XGeometry(display, DefaultScreen(display), NULL, "120x40-0-0", 1, 0, + 0, 4, 6, &wx, &wy, &ww, &wh); + CHECK(mask == NoValue, "XGeometry default-only mask wrong"); + CHECK(wx == DisplayWidth(display, DefaultScreen(display)) - 4 - 2 && + wy == DisplayHeight(display, DefaultScreen(display)) - 6 - 2, + "XGeometry did not preserve zero unit sizes"); + + /* The negative-anchor pixel-arithmetic path must saturate when the + * font cell times the parsed unit count overflows int. We drive + * the satMulAdd path with a small parseable spec (so upstream + * XParseGeometry's signed int math stays in range) and a huge + * font cell argument; clampInt64ToInt clamps the result. */ + int saturatedX = -1; + int saturatedY = -1; + int saturatedW = -1; + int saturatedH = -1; + XGeometry(display, DefaultScreen(display), "100x100-0-0", "10x10+0+0", 0, + INT_MAX / 2, INT_MAX / 2, 0, 0, &saturatedX, &saturatedY, + &saturatedW, &saturatedH); + CHECK(saturatedW == 100 && saturatedH == 100, + "XGeometry parsed width/height was clobbered"); + /* anchorPixelWidth = 100 * (INT_MAX/2) is far above INT_MAX, so + * DisplayWidth - anchorPixelWidth - border clamps to INT_MIN. */ + CHECK(saturatedX == INT_MIN, + "XGeometry negative-anchor X did not saturate at INT_MIN"); + CHECK(saturatedY == INT_MIN, + "XGeometry negative-anchor Y did not saturate at INT_MIN"); + return 1; } @@ -812,6 +1029,18 @@ static int pixel_is_rgba(SDL_Surface *surface, gotAlpha == alpha; } +static int pixel_is_between_black_and_white(SDL_Surface *surface, int x, int y) +{ + Uint8 red = 0; + Uint8 green = 0; + Uint8 blue = 0; + Uint8 alpha = 0; + SDL_GetRGBA(getPixel(surface, (unsigned int) x, (unsigned int) y), + surface->format, &red, &green, &blue, &alpha); + return alpha == 255 && red == green && green == blue && red > 0 && + red < 255; +} + static int test_pixmaps(Display *display) { int formatCount = 0; @@ -960,6 +1189,162 @@ static int test_pixmaps(Display *display) SDL_FreeSurface(surface); XFreePixmap(display, colorPixmap); + colorPixmap = XCreatePixmapFromBitmapData(display, root, colorBits, 2, 1, 0, + 0x00FFFFFF, 24); + CHECK(colorPixmap != None, + "XCreatePixmapFromBitmapData with X11 pixel colors failed"); + GET_RENDERER(colorPixmap, renderer); + surface = getRenderSurface(renderer); + CHECK(surface != NULL, + "getRenderSurface for X11 pixel color pixmap failed"); + CHECK(pixel_is_rgb(surface, 0, 0, 255, 255, 255), + "pixmap clear bit treated alpha-less white as transparent"); + CHECK(pixel_is_rgb(surface, 1, 0, 0, 0, 0), + "pixmap set bit treated X11 black pixel as transparent"); + SDL_FreeSurface(surface); + XFreePixmap(display, colorPixmap); + + Pixmap copySrc = XCreatePixmap( + display, root, 8, 8, DefaultDepth(display, DefaultScreen(display))); + Pixmap copyDest = XCreatePixmap( + display, root, 8, 8, DefaultDepth(display, DefaultScreen(display))); + CHECK(copySrc != None && copyDest != None, + "XCopyArea pixmap regression pixmap creation failed"); + GC copyGc = XCreateGC(display, copySrc, 0, NULL); + CHECK(copyGc != NULL, "XCopyArea pixmap regression GC creation failed"); + CHECK(XSetForeground(display, copyGc, 0xFFFFFFFF), + "XCopyArea pixmap regression source clear color failed"); + CHECK(XFillRectangle(display, copySrc, copyGc, 0, 0, 8, 8), + "XCopyArea pixmap regression source fill failed"); + CHECK(XSetForeground(display, copyGc, 0xFF000000), + "XCopyArea pixmap regression source mark color failed"); + CHECK(XFillRectangle(display, copySrc, copyGc, 2, 2, 3, 3), + "XCopyArea pixmap regression source mark failed"); + CHECK(XSetForeground(display, copyGc, 0xFFFFFFFF), + "XCopyArea pixmap regression destination clear color failed"); + CHECK(XFillRectangle(display, copyDest, copyGc, 0, 0, 8, 8), + "XCopyArea pixmap regression destination clear failed"); + CHECK(XCopyArea(display, copySrc, copyDest, copyGc, 2, 2, 3, 3, 0, 0), + "XCopyArea to pixmap failed"); + GET_RENDERER(copyDest, renderer); + surface = getRenderSurface(renderer); + CHECK(surface != NULL, + "getRenderSurface for XCopyArea pixmap destination failed"); + CHECK(pixel_is_rgb(surface, 0, 0, 0, 0, 0), + "XCopyArea to pixmap did not copy source pixels"); + CHECK(pixel_is_rgb(surface, 4, 4, 255, 255, 255), + "XCopyArea to pixmap damaged unrelated pixels"); + SDL_FreeSurface(surface); + + /* Non-GXcopy XCopyArea: the raster-op fallback should now respect + * the GC function. GXxor of opaque white over black yields white, + * GXand yields black. Skip the assertion if SDL_RenderReadPixels + * returns 0 (some headless SDL configs), but at minimum the call + * must report success. */ + CHECK(XSetForeground(display, copyGc, 0xFFFFFFFF), + "raster-op src fill color setup failed"); + CHECK(XFillRectangle(display, copySrc, copyGc, 0, 0, 8, 8), + "raster-op src fill failed"); + CHECK(XSetForeground(display, copyGc, 0xFF000000), + "raster-op dest clear color setup failed"); + CHECK(XFillRectangle(display, copyDest, copyGc, 0, 0, 8, 8), + "raster-op dest clear failed"); + XGCValues rasterValues; + rasterValues.function = GXxor; + CHECK(XChangeGC(display, copyGc, GCFunction, &rasterValues), + "raster-op GXxor configuration failed"); + CHECK(XCopyArea(display, copySrc, copyDest, copyGc, 0, 0, 8, 8, 0, 0) == 1, + "XCopyArea with GXxor failed"); + rasterValues.function = GXcopy; + XChangeGC(display, copyGc, GCFunction, &rasterValues); + /* Verify actual pixel output: white XOR black = white (0xFFFFFF). + * Without this assertion the test would pass even if the raster-op + * helper read the wrong renderer or fed uninitialized memory into + * applyRasterFunction. */ + { + SDL_Renderer *rasterRenderer = NULL; + GET_RENDERER(copyDest, rasterRenderer); + SDL_Surface *rasterSurface = getRenderSurface(rasterRenderer); + if (rasterSurface) { + CHECK(pixel_is_rgb(rasterSurface, 0, 0, 255, 255, 255), + "GXxor XCopyArea did not produce white = black ^ white"); + CHECK(pixel_is_rgb(rasterSurface, 4, 4, 255, 255, 255), + "GXxor XCopyArea did not cover the full rect"); + SDL_FreeSurface(rasterSurface); + } + } + + CHECK(XSetForeground(display, copyGc, 0xFF000000), + "clipped raster-op dest clear color setup failed"); + CHECK(XFillRectangle(display, copyDest, copyGc, 0, 0, 8, 8), + "clipped raster-op dest clear failed"); + rasterValues.function = GXxor; + CHECK(XChangeGC(display, copyGc, GCFunction, &rasterValues), + "clipped raster-op GXxor configuration failed"); + CHECK( + XCopyArea(display, copySrc, copyDest, copyGc, 0, 0, 4, 4, -2, -2) == 1, + "clipped GXxor XCopyArea failed"); + rasterValues.function = GXcopy; + XChangeGC(display, copyGc, GCFunction, &rasterValues); + { + SDL_Renderer *rasterRenderer = NULL; + GET_RENDERER(copyDest, rasterRenderer); + SDL_Surface *rasterSurface = getRenderSurface(rasterRenderer); + if (rasterSurface) { + CHECK(pixel_is_rgb(rasterSurface, 0, 0, 255, 255, 255), + "clipped GXxor XCopyArea did not write visible pixels"); + CHECK(pixel_is_rgb(rasterSurface, 2, 2, 0, 0, 0), + "clipped GXxor XCopyArea damaged outside clipped region"); + SDL_FreeSurface(rasterSurface); + } + } + + /* XCopyArea must reject geometries whose extents would overflow + * signed int instead of wrapping into SDL_Rect math. Combine a near- + * INT_MAX coordinate with a non-zero width to force the extent over + * the int boundary. */ + int (*xcOldHandler)(Display *, XErrorEvent *) = + XSetErrorHandler(record_error); + last_error_code = 0; + CHECK(XCopyArea(display, copySrc, copyDest, copyGc, INT_MAX, 0, 16u, 1u, 0, + 0) == 0, + "XCopyArea accepted src_x + width past INT_MAX"); + XSync(display, False); + CHECK(last_error_code == BadValue, + "XCopyArea src extent overflow did not raise BadValue"); + last_error_code = 0; + CHECK(XCopyArea(display, copySrc, copyDest, copyGc, 0, 0, 16u, 1u, INT_MAX, + 0) == 0, + "XCopyArea accepted dest_x + width past INT_MAX"); + XSync(display, False); + CHECK(last_error_code == BadValue, + "XCopyArea dest extent overflow did not raise BadValue"); + last_error_code = 0; + CHECK(XCopyArea(display, copySrc, copyDest, copyGc, 0, 0, + (unsigned int) INT_MAX + 1u, 1u, 0, 0) == 0, + "XCopyArea accepted width over INT_MAX"); + XSync(display, False); + CHECK(last_error_code == BadValue, + "XCopyArea width over INT_MAX did not raise BadValue"); + rasterValues.function = GXxor; + CHECK(XChangeGC(display, copyGc, GCFunction, &rasterValues), + "raster-op overflow GXxor configuration failed"); + last_error_code = 0; + CHECK(XCopyArea(display, copySrc, copyDest, copyGc, 0, 0, + (unsigned int) (INT_MAX / sizeof(Uint32)) + 1u, 1u, 0, + 0) == 0, + "XCopyArea accepted raster-op pitch past INT_MAX"); + XSync(display, False); + CHECK(last_error_code == BadValue, + "XCopyArea raster-op pitch overflow did not raise BadValue"); + rasterValues.function = GXcopy; + XChangeGC(display, copyGc, GCFunction, &rasterValues); + XSetErrorHandler(xcOldHandler); + + XFreeGC(display, copyGc); + XFreePixmap(display, copySrc); + XFreePixmap(display, copyDest); + /* XBM file round-trip: parse a hand-written file, then read back a * file produced by XWriteBitmapFile. */ char xbmPath[] = "/tmp/libx11-compat-bitmap-XXXXXX"; @@ -1172,6 +1557,106 @@ static int test_drawables_and_gcs(Display *display) "GXcopy arc fill blended instead of replacing"); SDL_FreeSurface(surface); + Window clipTop = + XCreateSimpleWindow(display, root, 120, 40, 24, 24, 0, 0, 0); + CHECK(clipTop != None, "child clip top-level creation failed"); + Window clipChild = + XCreateSimpleWindow(display, clipTop, 8, 8, 8, 8, 0, 0, 0); + CHECK(clipChild != None, "child clip window creation failed"); + CHECK(XMapWindow(display, clipChild), "child clip child map failed"); + CHECK(XMapWindow(display, clipTop), "child clip top-level map failed"); + GC childClipGc = XCreateGC(display, clipChild, 0, NULL); + CHECK(childClipGc != NULL, "child clip GC creation failed"); + CHECK(XSetForeground(display, childClipGc, 0xFF000000), + "child clip black failed"); + CHECK(XFillRectangle(display, clipTop, childClipGc, 0, 0, 24, 24), + "child clip top clear failed"); + XRectangle childClip = {.x = 0, .y = 0, .width = 4, .height = 4}; + CHECK( + XSetClipRectangles(display, childClipGc, 0, 0, &childClip, 1, Unsorted), + "child clip rect setup failed"); + CHECK(XSetForeground(display, childClipGc, 0xFFFF0000), + "child clip red failed"); + CHECK(XFillRectangle(display, clipChild, childClipGc, 0, 0, 8, 8), + "child clipped fill failed"); + XSetClipMask(display, childClipGc, None); + SDL_Renderer *clipTopRenderer = NULL; + GET_RENDERER(clipTop, clipTopRenderer); + SDL_Surface *clipTopSurface = getRenderSurface(clipTopRenderer); + CHECK(clipTopSurface != NULL, "child clip surface readback failed"); + CHECK(pixel_is_rgb(clipTopSurface, 8, 8, 255, 0, 0), + "child clip missed drawable-local inside pixel"); + CHECK(pixel_is_rgb(clipTopSurface, 12, 8, 0, 0, 0), + "child clip allowed outside pixel"); + SDL_FreeSurface(clipTopSurface); + + CHECK(XSetClipMask(display, childClipGc, None), + "child copy clip reset failed"); + CHECK(XSetForeground(display, childClipGc, 0xFF000000), + "child copy black failed"); + CHECK(XFillRectangle(display, clipTop, childClipGc, 0, 0, 24, 24), + "child copy top reset failed"); + CHECK(XMoveResizeWindow(display, clipChild, 5, 5, 8, 8), + "child copy geometry reset failed"); + CHECK(XSetForeground(display, childClipGc, 0xFFFF0000), + "child copy red failed"); + CHECK(XFillRectangle(display, clipChild, childClipGc, 0, 0, 8, 4), + "child copy top fill failed"); + CHECK(XSetForeground(display, childClipGc, 0xFF00FF00), + "child copy green failed"); + CHECK(XFillRectangle(display, clipChild, childClipGc, 0, 4, 8, 4), + "child copy bottom fill failed"); + CHECK( + XCopyArea(display, clipChild, clipChild, childClipGc, 0, 4, 8, 4, 0, 0), + "child same-window XCopyArea failed"); + GET_RENDERER(clipTop, clipTopRenderer); + clipTopSurface = getRenderSurface(clipTopRenderer); + CHECK(clipTopSurface != NULL, "child copy surface readback failed"); + CHECK(pixel_is_rgb(clipTopSurface, 5, 5, 0, 255, 0), + "child same-window XCopyArea read from wrong coordinates"); + SDL_FreeSurface(clipTopSurface); + + CHECK(XSetClipMask(display, childClipGc, None), + "child move clear clip reset failed"); + CHECK(XSetForeground(display, childClipGc, 0xFF000000), + "child move clear black failed"); + CHECK(XFillRectangle(display, clipTop, childClipGc, 0, 0, 24, 24), + "child move clear top reset failed"); + CHECK(XSetForeground(display, childClipGc, 0xFFFF0000), + "child move clear red failed"); + CHECK(XFillRectangle(display, clipChild, childClipGc, 0, 0, 8, 8), + "child move clear initial child fill failed"); + CHECK(XMoveWindow(display, clipChild, 12, 12), + "child move clear move failed"); + GET_RENDERER(clipTop, clipTopRenderer); + clipTopSurface = getRenderSurface(clipTopRenderer); + CHECK(clipTopSurface != NULL, "child move clear surface readback failed"); + CHECK(pixel_is_rgb(clipTopSurface, 8, 8, 0, 0, 0), + "moving child left stale pixels in old parent area"); + SDL_FreeSurface(clipTopSurface); + + CHECK(XSetForeground(display, childClipGc, 0xFF000000), + "child resize gravity black failed"); + CHECK(XFillRectangle(display, clipTop, childClipGc, 0, 0, 24, 24), + "child resize gravity top reset failed"); + CHECK(XMoveResizeWindow(display, clipChild, 4, 4, 8, 8), + "child resize gravity reset failed"); + CHECK(XSetForeground(display, childClipGc, 0xFFFF0000), + "child resize gravity red failed"); + CHECK(XFillRectangle(display, clipChild, childClipGc, 0, 0, 8, 8), + "child resize gravity initial fill failed"); + CHECK(XResizeWindow(display, clipChild, 10, 10), + "child resize gravity resize failed"); + GET_RENDERER(clipTop, clipTopRenderer); + clipTopSurface = getRenderSurface(clipTopRenderer); + CHECK(clipTopSurface != NULL, + "child resize gravity surface readback failed"); + CHECK(pixel_is_rgb(clipTopSurface, 4, 4, 0, 0, 0), + "ForgetGravity resize preserved stale child pixels"); + SDL_FreeSurface(clipTopSurface); + XFreeGC(display, childClipGc); + XDestroyWindow(display, clipTop); + Pixmap pathArcPixmap = XCreatePixmap(display, root, 40, 40, DefaultDepth(display, 0)); CHECK(pathArcPixmap != None, "path arc pixmap creation failed"); @@ -1722,6 +2207,37 @@ static int test_images(Display *display) "XGetImage XYPixmap returned a zero-filled image"); XDestroyImage(xyReadback); + /* Verify the ZPixmap 32-bit fast path promotes X11 pixel colors + * (upper byte == 0) to opaque alpha so SDL2 renders them visible. + * Without the alpha promotion the entire surface would read as + * fully transparent. */ + char *alphaTestData = calloc(1, 16); + CHECK(alphaTestData, "alpha-promotion image data allocation failed"); + XImage *alphaImage = + XCreateImage(display, DefaultVisual(display, DefaultScreen(display)), + 32, ZPixmap, 0, alphaTestData, 2, 2, 32, 0); + CHECK(alphaImage, "alpha-promotion XCreateImage failed"); + XPutPixel(alphaImage, 0, 0, 0x00FFFFFF); + XPutPixel(alphaImage, 1, 0, 0x00000000); + Pixmap alphaPixmap = + XCreatePixmap(display, secondWindow, 2, 1, + DefaultDepth(display, DefaultScreen(display))); + CHECK(alphaPixmap != None, "alpha-promotion pixmap creation failed"); + CHECK(XPutImage(display, alphaPixmap, imageGc, alphaImage, 0, 0, 0, 0, 2, + 1) == 1, + "ZPixmap XPutImage with X11 pixel colors failed"); + SDL_Renderer *alphaRenderer = NULL; + GET_RENDERER(alphaPixmap, alphaRenderer); + SDL_Surface *alphaSurface = getRenderSurface(alphaRenderer); + CHECK(alphaSurface, "alpha-promotion render surface unavailable"); + CHECK(pixel_is_rgb(alphaSurface, 0, 0, 255, 255, 255), + "ZPixmap XPutImage treated alpha-less white as transparent"); + CHECK(pixel_is_rgb(alphaSurface, 1, 0, 0, 0, 0), + "ZPixmap XPutImage treated alpha-less black as transparent"); + SDL_FreeSurface(alphaSurface); + XFreePixmap(display, alphaPixmap); + XDestroyImage(alphaImage); + XSetWindowAttributes inputAttrs; memset(&inputAttrs, 0, sizeof(inputAttrs)); Window inputOnly = @@ -1937,6 +2453,13 @@ static int test_events(Display *display) GET_RENDERER(window, renderer); CHECK(renderer, "mapped window renderer was not available"); XEvent out; + int mapExposeCount = 0; + while (XCheckWindowEvent(display, window, ExposureMask, &out)) { + CHECK(out.type == Expose && out.xexpose.count == 0, + "map Expose had unexpected type/count"); + mapExposeCount++; + } + CHECK(mapExposeCount == 1, "XMapWindow posted duplicate Expose events"); while (XCheckWindowEvent(display, window, ExposureMask, &out)) { } CHECK(XClearArea(display, window, 3, 4, 0, 0, True), "XClearArea failed"); @@ -1964,6 +2487,29 @@ static int test_events(Display *display) while (XCheckTypedWindowEvent(display, window, Expose, &out)) { } + /* XSendEvent's contract is sizeof(XEvent), so the buffer must be + * full-union sized; passing a bare XClientMessageEvent triggers a + * stack-buffer-overflow under ASan. */ + XEvent olderClient; + memset(&olderClient, 0, sizeof(olderClient)); + olderClient.xclient.type = ClientMessage; + olderClient.xclient.display = display; + olderClient.xclient.window = window; + olderClient.xclient.message_type = XA_STRING; + olderClient.xclient.format = 32; + olderClient.xclient.data.l[0] = 0x1234; + CHECK(XSendEvent(display, window, False, NoEventMask, &olderClient), + "event-order ClientMessage send failed"); + CHECK(XClearArea(display, window, 21, 22, 5, 6, True), + "event-order XClearArea failed"); + XNextEvent(display, &out); + CHECK(out.type == ClientMessage && out.xany.window == window, + "generated Expose jumped ahead of older queued SDL event"); + XNextEvent(display, &out); + CHECK(out.type == Expose && out.xany.window == window && + out.xexpose.x == 21 && out.xexpose.y == 22, + "generated Expose was not delivered after older SDL event"); + GC parentDrawGc = XCreateGC(display, window, 0, NULL); CHECK(parentDrawGc != NULL, "parent child-overlap GC creation failed"); CHECK(XDrawLine(display, window, parentDrawGc, 0, 14, 31, 14), @@ -2057,6 +2603,30 @@ static int test_events(Display *display) CHECK(translatedChild == offsetChild, "XTranslateCoordinates did not return containing child"); + Window lowerOverlap = + XCreateSimpleWindow(display, offsetWindow, 6, 6, 8, 8, 0, 0, 0); + CHECK(lowerOverlap != None, "lower overlap child creation failed"); + Window upperOverlap = + XCreateSimpleWindow(display, offsetWindow, 8, 8, 8, 8, 0, 0, 0); + CHECK(upperOverlap != None, "upper overlap child creation failed"); + CHECK(XMapWindow(display, lowerOverlap), "lower overlap child map failed"); + CHECK(XMapWindow(display, upperOverlap), "upper overlap child map failed"); + CHECK(XTranslateCoordinates(display, offsetWindow, offsetWindow, 9, 9, + &translatedX, &translatedY, &translatedChild), + "XTranslateCoordinates overlap lookup failed"); + CHECK(translatedChild == upperOverlap, + "XTranslateCoordinates did not return topmost child"); + XUnmapWindow(display, upperOverlap); + CHECK(XTranslateCoordinates(display, offsetWindow, offsetWindow, 9, 9, + &translatedX, &translatedY, &translatedChild), + "XTranslateCoordinates unmapped overlap lookup failed"); + CHECK(translatedChild == lowerOverlap, + "XTranslateCoordinates returned an unmapped child"); + /* Tear the offsetWindow subtree down before the rest of the test + * reuses `window` for unrelated checks; XDestroyWindow recursively + * frees offsetChild, lowerOverlap, and upperOverlap. */ + XDestroyWindow(display, offsetWindow); + Pixmap backgroundPixmap = XCreatePixmap( display, window, 4, 4, DefaultDepth(display, DefaultScreen(display))); CHECK(backgroundPixmap != None, "XCreatePixmap for background failed"); @@ -2120,6 +2690,16 @@ static int test_events(Display *display) CHECK(out.type == Expose && out.xany.window == window, "XPutBackEvent disturbed queued event order"); + XSendEvent(display, window, False, 0, &client); + XNextEvent(display, &out); + CHECK(out.type == ClientMessage && out.xany.window == window, + "XNextEvent did not drain normal event after empty put-back check"); + CHECK(XPutBackEvent(display, &client) == 0, + "XPutBackEvent deadlocked after empty put-back pop"); + XNextEvent(display, &out); + CHECK(out.type == ClientMessage && out.xany.window == window, + "post-empty-pop XPutBackEvent was not readable"); + XSendEvent(display, window, False, ExposureMask, &expose); CHECK(XCheckMaskEvent(display, ExposureMask, &out), "XCheckMaskEvent did not find Expose"); @@ -2268,6 +2848,94 @@ static int test_events(Display *display) while (XCheckTypedWindowEvent(display, pointerChild, Expose, &out)) { } + XSelectInput(display, pointerChild, EnterWindowMask); + SDL_Event enterOnlyMotion; + SDL_zero(enterOnlyMotion); + enterOnlyMotion.type = SDL_MOUSEMOTION; + enterOnlyMotion.motion.windowID = + SDL_GetWindowID(GET_WINDOW_STRUCT(pointerParent)->sdlWindow); + enterOnlyMotion.motion.x = 15; + enterOnlyMotion.motion.y = 18; + CHECK(convertEvent(display, &enterOnlyMotion, &out, True) != 0, + "enter-only child unexpectedly received MotionNotify"); + CHECK(XCheckTypedWindowEvent(display, pointerChild, EnterNotify, &out), + "enter-only child did not receive crossing event"); + CHECK(out.xcrossing.x == 5 && out.xcrossing.y == 6, + "enter-only child crossing coordinates were not child-local"); + CHECK(!XCheckTypedWindowEvent(display, pointerChild, MotionNotify, &out), + "enter-only child queued MotionNotify without selecting motion"); + + XSelectInput(display, pointerChild, LeaveWindowMask); + enterOnlyMotion.motion.x = 70; + enterOnlyMotion.motion.y = 70; + CHECK(convertEvent(display, &enterOnlyMotion, &out, True) != 0, + "leave-only child unexpectedly received MotionNotify"); + CHECK(XCheckTypedWindowEvent(display, pointerChild, LeaveNotify, &out), + "leave-only child did not receive crossing event"); + + XSelectInput(display, pointerChild, EnterWindowMask | PointerMotionMask); + enterOnlyMotion.motion.x = 15; + enterOnlyMotion.motion.y = 18; + CHECK(convertEvent(display, &enterOnlyMotion, &out, True) != 0, + "crossing motion should be queued behind EnterNotify"); + XNextEvent(display, &out); + CHECK(out.type == EnterNotify && out.xcrossing.window == pointerChild, + "crossing motion did not deliver EnterNotify first"); + XNextEvent(display, &out); + CHECK(out.type == MotionNotify && out.xmotion.window == pointerChild, + "crossing motion did not deliver MotionNotify after EnterNotify"); + + XSelectInput(display, pointerParent, EnterWindowMask | LeaveWindowMask); + XSelectInput(display, pointerChild, EnterWindowMask | LeaveWindowMask); + enterOnlyMotion.motion.x = 70; + enterOnlyMotion.motion.y = 70; + CHECK(convertEvent(display, &enterOnlyMotion, &out, True) != 0, + "child-to-parent crossing unexpectedly produced MotionNotify"); + CHECK(XCheckTypedWindowEvent(display, pointerChild, LeaveNotify, &out), + "child-to-parent crossing did not leave child"); + CHECK(out.xcrossing.detail == NotifyAncestor, + "child-to-parent LeaveNotify did not use NotifyAncestor"); + CHECK(XCheckTypedWindowEvent(display, pointerParent, EnterNotify, &out), + "child-to-parent crossing did not enter parent"); + CHECK(out.xcrossing.detail == NotifyInferior, + "child-to-parent EnterNotify did not use NotifyInferior"); + + enterOnlyMotion.motion.x = 15; + enterOnlyMotion.motion.y = 18; + CHECK(convertEvent(display, &enterOnlyMotion, &out, True) != 0, + "parent-to-child crossing unexpectedly produced MotionNotify"); + CHECK(XCheckTypedWindowEvent(display, pointerParent, LeaveNotify, &out), + "parent-to-child crossing did not leave parent"); + CHECK(out.xcrossing.detail == NotifyInferior, + "parent-to-child LeaveNotify did not use NotifyInferior"); + CHECK(XCheckTypedWindowEvent(display, pointerChild, EnterNotify, &out), + "parent-to-child crossing did not enter child"); + CHECK(out.xcrossing.detail == NotifyAncestor, + "parent-to-child EnterNotify did not use NotifyAncestor"); + + SDL_Event topLeave; + SDL_zero(topLeave); + topLeave.type = SDL_WINDOWEVENT; + topLeave.window.windowID = + SDL_GetWindowID(GET_WINDOW_STRUCT(pointerParent)->sdlWindow); + topLeave.window.event = SDL_WINDOWEVENT_LEAVE; + CHECK(convertEvent(display, &topLeave, &out, True) != 0, + "top-level leave should queue child leave first"); + XNextEvent(display, &out); + CHECK(out.type == LeaveNotify && out.xcrossing.window == pointerChild, + "top-level leave did not deliver nested child LeaveNotify first"); + CHECK(out.xcrossing.x_root == 15 && out.xcrossing.y_root == 18, + "nested child LeaveNotify did not preserve root coordinates"); + CHECK(out.xcrossing.x == 5 && out.xcrossing.y == 6, + "nested child LeaveNotify did not preserve child-local coordinates"); + XNextEvent(display, &out); + CHECK(out.type == LeaveNotify && out.xcrossing.window == pointerParent, + "top-level leave did not deliver parent LeaveNotify after child"); + + XSelectInput(display, pointerParent, NoEventMask); + XSelectInput(display, pointerChild, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask); + SDL_Event buttonEvent; SDL_zero(buttonEvent); buttonEvent.type = SDL_MOUSEBUTTONDOWN; @@ -2328,6 +2996,143 @@ static int test_events(Display *display) CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, "offset SDL button release did not convert"); + Window unmappedCover = XCreateSimpleWindow(display, offsetPointerParent, 0, + 0, 80, 80, 0, 0, 0); + CHECK(unmappedCover != None, "unmapped cover creation failed"); + XSelectInput(display, unmappedCover, ButtonPressMask); + XSelectInput(display, offsetPointerChild, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "covered SDL button did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == offsetPointerChild, + "unmapped sibling stole pointer hit-test from visible child"); + CHECK(out.xbutton.subwindow == None, + "button event reported an unmapped sibling as subwindow"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "covered SDL button release did not convert"); + + CHECK(XGrabButton(display, Button1, AnyModifier, offsetPointerChild, False, + ButtonPressMask | ButtonReleaseMask, GrabModeAsync, + GrabModeAsync, None, None) == Success, + "nested passive button grab failed"); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "nested passive-grab button press did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == offsetPointerChild, + "nested passive grab did not activate on deepest child"); + CHECK(out.xbutton.subwindow == None, + "nested passive grab reported unexpected subwindow"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "nested passive-grab button release did not convert"); + CHECK(out.type == ButtonRelease && out.xbutton.window == offsetPointerChild, + "nested passive grab did not keep release on grab window"); + CHECK(XUngrabButton(display, Button1, AnyModifier, offsetPointerChild) == + Success, + "nested passive button ungrab failed"); + + CHECK(XGrabButton(display, Button1, AnyModifier, offsetPointerParent, False, + ButtonPressMask | ButtonReleaseMask, GrabModeAsync, + GrabModeAsync, None, None) == Success, + "passive button grab failed"); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "passive-grab button press did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == offsetPointerParent, + "passive button grab did not route press to grab window"); + CHECK(out.xbutton.subwindow == offsetPointerChild, + "passive button grab did not preserve direct subwindow"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "passive-grab button release did not convert"); + CHECK( + out.type == ButtonRelease && out.xbutton.window == offsetPointerParent, + "passive button grab did not keep release on grab window"); + CHECK(XUngrabButton(display, Button1, AnyModifier, offsetPointerParent) == + Success, + "passive button ungrab failed"); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "post-passive-ungrab button press did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == offsetPointerChild, + "post-passive-ungrab press did not return to normal hit-testing"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "post-passive-ungrab button release did not convert"); + + CHECK(XGrabButton(display, Button1, AnyModifier, offsetPointerParent, False, + ButtonPressMask | ButtonReleaseMask, GrabModeSync, + GrabModeAsync, None, None) == Success, + "sync passive button grab failed"); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "sync passive-grab button press did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == offsetPointerParent, + "sync passive button grab did not route press to grab window"); + CHECK(mouseFrozen, "sync passive button grab did not freeze pointer"); + CHECK(XAllowEvents(display, ReplayPointer, CurrentTime), + "ReplayPointer failed for passive button grab"); + CHECK(!mouseFrozen, "ReplayPointer did not thaw pointer"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "post-ReplayPointer button release did not convert"); + CHECK(out.type == ButtonRelease && out.xbutton.window == offsetPointerChild, + "ReplayPointer did not release passive button grab routing"); + CHECK(XUngrabButton(display, Button1, AnyModifier, offsetPointerParent) == + Success, + "sync passive button ungrab failed"); + + CHECK(XGrabButton(display, Button1, AnyModifier, offsetPointerParent, True, + ButtonReleaseMask, GrabModeSync, GrabModeAsync, None, + None) == Success, + "Motif-style passive button grab failed"); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "Motif-style passive-grab button press did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == offsetPointerChild, + "owner-events passive grab did not deliver press to selected child"); + CHECK(mouseFrozen, "Motif-style sync passive grab did not freeze pointer"); + CHECK(XGrabPointer(display, offsetPointerParent, True, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask, + GrabModeSync, GrabModeAsync, None, None, + CurrentTime) == GrabSuccess, + "XGrabPointer did not upgrade active passive grab"); + CHECK(mouseFrozen, "upgraded menu pointer grab did not preserve sync mode"); + CHECK(XUngrabPointer(display, CurrentTime), + "upgraded menu pointer grab did not ungrab"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "Motif-style passive-grab button release did not convert"); + CHECK(XUngrabButton(display, Button1, AnyModifier, offsetPointerParent) == + Success, + "Motif-style passive button ungrab failed"); + + SDL_zero(hintMotion); + hintMotion.type = SDL_MOUSEMOTION; + hintMotion.motion.windowID = + SDL_GetWindowID(GET_WINDOW_STRUCT(offsetPointerParent)->sdlWindow); + hintMotion.motion.x = 14; + hintMotion.motion.y = 17; + XSelectInput(display, offsetPointerChild, Button1MotionMask); + CHECK(convertEvent(display, &hintMotion, &out, True) < 0, + "Button1MotionMask received motion with no held Button1"); + XSelectInput(display, offsetPointerChild, + ButtonPressMask | ButtonReleaseMask | Button1MotionMask); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "Button1Motion setup press did not convert"); + CHECK(convertEvent(display, &hintMotion, &out, True) == 0, + "Button1MotionMask missed drag motion with held Button1"); + CHECK(out.type == MotionNotify && out.xmotion.window == offsetPointerChild, + "Button1MotionMask drag targeted the wrong window"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "Button1Motion cleanup release did not convert"); + XSelectInput(display, offsetPointerChild, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask); + SDL_zero(buttonEvent); buttonEvent.type = SDL_MOUSEBUTTONDOWN; buttonEvent.button.windowID = @@ -2441,22 +3246,154 @@ static int test_events(Display *display) EnterWindowMask | LeaveWindowMask, GrabModeSync, GrabModeAsync, None, None, CurrentTime) == GrabSuccess, "XGrabPointer failed"); - CHECK(SDL_GetRelativeMouseMode() == SDL_FALSE, - "XGrabPointer enabled SDL relative mouse mode"); CHECK(mouseFrozen, "XGrabPointer did not freeze sync pointer mode"); CHECK(XCheckTypedWindowEvent(display, window, EnterNotify, &out), "XGrabPointer did not post EnterNotify"); CHECK(out.xcrossing.mode == NotifyGrab, "XGrabPointer EnterNotify used wrong mode"); + /* A second active grab by the same client is a regrab: it updates the + * active grab instead of reporting AlreadyGrabbed. */ + CHECK(XGrabPointer(display, window, False, + ButtonPressMask | ButtonReleaseMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime) == GrabSuccess, + "same-client XGrabPointer regrab did not report GrabSuccess"); + CHECK(!mouseFrozen, "same-client XGrabPointer regrab did not thaw pointer"); + CHECK(getPointerGrabEventMask() == (ButtonPressMask | ButtonReleaseMask), + "same-client XGrabPointer regrab did not update event mask"); + /* A grab on None or on a non-window must report BadWindow. */ + CHECK(XGrabPointer(display, None, False, 0, GrabModeAsync, GrabModeAsync, + None, None, CurrentTime) == BadWindow, + "XGrabPointer(None) did not report BadWindow"); + CHECK(SDL_GetRelativeMouseMode() == SDL_FALSE, + "XGrabPointer enabled SDL relative mouse mode"); + CHECK(XAllowEvents(display, SyncPointer, CurrentTime), + "XAllowEvents(SyncPointer) failed"); + CHECK(!mouseFrozen, "SyncPointer did not thaw pointer freeze"); + CHECK(getGrabbedPointerWindow() == window, + "SyncPointer released the active pointer grab"); CHECK(XAllowEvents(display, AsyncPointer, CurrentTime), "XAllowEvents failed"); - CHECK(!mouseFrozen, "XAllowEvents did not release pointer freeze"); + CHECK(!mouseFrozen, "AsyncPointer did not keep pointer thawed"); CHECK(XUngrabPointer(display, CurrentTime), "XUngrabPointer failed"); CHECK(XCheckTypedWindowEvent(display, window, LeaveNotify, &out), "XUngrabPointer did not post LeaveNotify"); CHECK(out.xcrossing.mode == NotifyUngrab, "XUngrabPointer LeaveNotify used wrong mode"); + Window viewableRequestedChild = + XCreateSimpleWindow(display, window, 3, 4, 16, 16, 0, 0, 0); + CHECK(viewableRequestedChild != None, + "viewable MapRequested child creation failed"); + CHECK(XMapWindow(display, viewableRequestedChild), + "viewable MapRequested child map failed"); + GET_WINDOW_STRUCT(viewableRequestedChild)->mapState = MapRequested; + XWindowAttributes requestedAttrs; + CHECK( + XGetWindowAttributes(display, viewableRequestedChild, &requestedAttrs), + "viewable MapRequested child attributes failed"); + CHECK(requestedAttrs.map_state == IsViewable, + "test setup did not produce an effectively viewable child"); + CHECK(XGrabPointer(display, viewableRequestedChild, False, ButtonPressMask, + GrabModeAsync, GrabModeAsync, None, None, + CurrentTime) == GrabSuccess, + "XGrabPointer rejected effectively viewable child"); + CHECK(XUngrabPointer(display, CurrentTime), + "effectively viewable child pointer ungrab failed"); + + CHECK(XGrabPointer(display, pointerChild, False, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask, + GrabModeAsync, GrabModeAsync, None, None, + CurrentTime) == GrabSuccess, + "explicit pointer grab failed"); + SDL_zero(buttonEvent); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + buttonEvent.button.windowID = + SDL_GetWindowID(GET_WINDOW_STRUCT(offsetPointerParent)->sdlWindow); + buttonEvent.button.x = 14; + buttonEvent.button.y = 17; + buttonEvent.button.button = SDL_BUTTON_LEFT; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "explicit-grab SDL button did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == pointerChild, + "explicit pointer grab did not route ButtonPress to grab window"); + CHECK(out.xbutton.x == 94 && out.xbutton.y == 75 && + out.xbutton.x_root == 104 && out.xbutton.y_root == 87, + "explicit pointer grab ButtonPress coordinates were wrong"); + SDL_zero(hintMotion); + hintMotion.type = SDL_MOUSEMOTION; + hintMotion.motion.windowID = + SDL_GetWindowID(GET_WINDOW_STRUCT(offsetPointerParent)->sdlWindow); + hintMotion.motion.x = 16; + hintMotion.motion.y = 19; + CHECK(convertEvent(display, &hintMotion, &out, True) == 0, + "explicit-grab SDL motion did not convert"); + CHECK(out.type == MotionNotify && out.xmotion.window == pointerChild, + "explicit pointer grab did not route MotionNotify to grab window"); + CHECK(out.xmotion.x == 96 && out.xmotion.y == 77 && + out.xmotion.x_root == 106 && out.xmotion.y_root == 89, + "explicit pointer grab MotionNotify coordinates were wrong"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + buttonEvent.button.x = 16; + buttonEvent.button.y = 19; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "explicit-grab SDL button release did not convert"); + CHECK(out.type == ButtonRelease && out.xbutton.window == pointerChild, + "explicit pointer grab did not route ButtonRelease to grab window"); + CHECK(XUngrabPointer(display, CurrentTime), + "explicit pointer ungrab failed"); + while (XCheckTypedWindowEvent(display, pointerChild, LeaveNotify, &out)) { + } + + CHECK(XGrabPointer(display, pointerChild, False, + ButtonPressMask | ButtonReleaseMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime) == GrabSuccess, + "explicit pointer grab for ungrab-release failed"); + SDL_zero(buttonEvent); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + buttonEvent.button.windowID = + SDL_GetWindowID(GET_WINDOW_STRUCT(offsetPointerParent)->sdlWindow); + buttonEvent.button.x = 14; + buttonEvent.button.y = 17; + buttonEvent.button.button = SDL_BUTTON_LEFT; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "explicit-grab press before ungrab did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == pointerChild, + "explicit pointer grab before ungrab did not route to grab window"); + CHECK(XUngrabPointer(display, CurrentTime), + "explicit pointer ungrab during button hold failed"); + while (XCheckTypedWindowEvent(display, pointerChild, LeaveNotify, &out)) { + } + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "post-ungrab button release did not convert"); + CHECK(out.type == ButtonRelease && out.xbutton.window == offsetPointerChild, + "post-ungrab release kept routing to stale grab window"); + CHECK((out.xbutton.state & Button1Mask) != 0, + "post-ungrab release lost the held Button1 state"); + + CHECK(XGrabPointer(display, pointerChild, True, ButtonPressMask, + GrabModeAsync, GrabModeAsync, None, None, + CurrentTime) == GrabSuccess, + "owner-events pointer grab failed"); + SDL_zero(buttonEvent); + buttonEvent.type = SDL_MOUSEBUTTONDOWN; + buttonEvent.button.windowID = + SDL_GetWindowID(GET_WINDOW_STRUCT(offsetPointerParent)->sdlWindow); + buttonEvent.button.x = 14; + buttonEvent.button.y = 17; + buttonEvent.button.button = SDL_BUTTON_LEFT; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "owner-events grab SDL button did not convert"); + CHECK(out.type == ButtonPress && out.xbutton.window == offsetPointerChild, + "owner-events pointer grab did not preserve normal owner routing"); + buttonEvent.type = SDL_MOUSEBUTTONUP; + CHECK(convertEvent(display, &buttonEvent, &out, True) == 0, + "owner-events grab SDL button release did not convert"); + CHECK(XUngrabPointer(display, CurrentTime), + "owner-events pointer ungrab failed"); + while (XCheckTypedWindowEvent(display, pointerChild, LeaveNotify, &out)) { + } + /* CWOverrideRedirect must round-trip through XGetWindowAttributes. */ XSetWindowAttributes ovAttrs; ovAttrs.override_redirect = True; @@ -2467,9 +3404,11 @@ static int test_events(Display *display) CHECK(ovQuery.override_redirect == True, "CWOverrideRedirect did not stick on the window"); - XSelectInput(display, window, StructureNotifyMask); + XSelectInput(display, window, StructureNotifyMask | ExposureMask); while (XCheckTypedWindowEvent(display, window, ConfigureNotify, &out)) { } + while (XCheckTypedWindowEvent(display, window, Expose, &out)) { + } unsigned long resizeRequest = XNextRequest(display); CHECK(XResizeWindow(display, window, 52, 41), "XResizeWindow for serial check failed"); @@ -2477,6 +3416,8 @@ static int test_events(Display *display) "XResizeWindow did not post ConfigureNotify"); CHECK(out.xconfigure.serial >= resizeRequest, "ConfigureNotify serial did not satisfy the triggering request"); + while (XCheckTypedWindowEvent(display, window, Expose, &out)) { + } SDL_Event resizeEvent; SDL_zero(resizeEvent); @@ -2492,12 +3433,34 @@ static int test_events(Display *display) "SDL resize converted to the wrong X event"); CHECK(out.xconfigure.width == 48 && out.xconfigure.height == 40, "ConfigureNotify did not report the SDL resize dimensions"); + CHECK(XCheckTypedWindowEvent(display, window, Expose, &out), + "SDL resize did not post a full-window Expose"); + CHECK(out.xexpose.x == 0 && out.xexpose.y == 0 && out.xexpose.width == 48 && + out.xexpose.height == 40, + "SDL resize Expose rectangle was incorrect"); int textureWidth = 0; int textureHeight = 0; SDL_QueryTexture(GET_WINDOW_STRUCT(window)->sdlTexture, NULL, NULL, &textureWidth, &textureHeight); CHECK(textureWidth == 48 && textureHeight == 40, "backing texture did not resize to SDL dimensions"); + GC resizeGc = XCreateGC(display, window, 0, NULL); + CHECK(resizeGc != NULL, "resize backing GC creation failed"); + CHECK(XSetForeground(display, resizeGc, 0xFFFF0000), + "resize backing red foreground failed"); + CHECK(XFillRectangle(display, window, resizeGc, 0, 0, 12, 12), + "resize backing stale fill failed"); + CHECK(convertEvent(display, &resizeEvent, &out, True) == 0, + "second SDL resize did not convert to ConfigureNotify"); + SDL_Renderer *resizeRenderer = NULL; + GET_RENDERER(window, resizeRenderer); + CHECK(resizeRenderer != NULL, "resize backing renderer lookup failed"); + SDL_Surface *resizeSurface = getRenderSurface(resizeRenderer); + CHECK(resizeSurface != NULL, "resize backing readback failed"); + CHECK(!pixel_is_rgb(resizeSurface, 1, 1, 255, 0, 0), + "top-level resize preserved stale backing pixels"); + SDL_FreeSurface(resizeSurface); + XFreeGC(display, resizeGc); SDL_Event windowEvent; SDL_zero(windowEvent); windowEvent.type = SDL_WINDOWEVENT; @@ -3083,6 +4046,7 @@ static int test_windows(Display *display) "sibling restack above returned unexpected child order"); XFree(children); + XEvent visibility; XSelectInput(display, child1, VisibilityChangeMask); Window delayedParent = XCreateSimpleWindow(display, root, 10, 10, 32, 32, 0, 0, 0); @@ -3090,15 +4054,40 @@ static int test_windows(Display *display) XCreateSimpleWindow(display, delayedParent, 1, 1, 8, 8, 0, 0, 0); CHECK(delayedParent != None && delayedChild != None, "delayed map window creation failed"); + XSelectInput(display, delayedParent, SubstructureNotifyMask); + XSelectInput(display, delayedChild, StructureNotifyMask | ExposureMask); CHECK(XMapWindow(display, delayedChild), "mapping child of unmapped parent failed"); CHECK(expect_map_state(display, delayedChild, IsUnviewable), "child mapped under unmapped parent should be unviewable"); CHECK(GET_WINDOW_STRUCT(delayedParent)->sdlTexture == NULL, "unmapped parent should not merge map-requested child backing"); + CHECK( + !XCheckTypedWindowEvent(display, delayedParent, MapNotify, &visibility), + "map-requested child notified parent before ancestor mapped"); + CHECK( + !XCheckTypedWindowEvent(display, delayedChild, MapNotify, &visibility), + "map-requested child notified itself before ancestor mapped"); CHECK(XMapWindow(display, delayedParent), "mapping delayed parent failed"); CHECK(expect_map_state(display, delayedChild, IsViewable), "map-requested child was not viewable after parent map"); + CHECK( + XCheckTypedWindowEvent(display, delayedParent, MapNotify, &visibility), + "map-requested child did not notify parent on realization"); + CHECK(visibility.xmap.event == delayedParent && + visibility.xmap.window == delayedChild, + "map-requested child parent MapNotify fields were wrong"); + CHECK(XCheckTypedWindowEvent(display, delayedChild, MapNotify, &visibility), + "map-requested child did not notify itself on realization"); + CHECK(visibility.xmap.event == delayedChild && + visibility.xmap.window == delayedChild, + "map-requested child self MapNotify fields were wrong"); + CHECK(XCheckTypedWindowEvent(display, delayedChild, Expose, &visibility), + "map-requested child did not receive realization Expose"); + CHECK(visibility.xexpose.x == 0 && visibility.xexpose.y == 0 && + visibility.xexpose.width == 8 && visibility.xexpose.height == 8 && + visibility.xexpose.count == 0, + "map-requested child realization Expose fields were wrong"); CHECK(XUnmapWindow(display, delayedParent), "unmapping delayed parent failed"); CHECK(expect_map_state(display, delayedChild, IsUnviewable), @@ -3107,6 +4096,12 @@ static int test_windows(Display *display) "remapping delayed parent failed"); CHECK(expect_map_state(display, delayedChild, IsViewable), "descendant did not become viewable after ancestor remap"); + CHECK(XCheckTypedWindowEvent(display, delayedChild, Expose, &visibility), + "mapped descendant did not receive Expose after ancestor remap"); + CHECK(visibility.xexpose.x == 0 && visibility.xexpose.y == 0 && + visibility.xexpose.width == 8 && visibility.xexpose.height == 8 && + visibility.xexpose.count == 0, + "mapped descendant remap Expose fields were wrong"); XDestroyWindow(display, delayedParent); Window delayedSubParent = @@ -3117,6 +4112,15 @@ static int test_windows(Display *display) "delayed XMapSubwindows setup failed"); CHECK(XMapWindow(display, delayedSubParent), "mapping XMapSubwindows parent failed"); + Window directMapChild = + XCreateSimpleWindow(display, delayedSubParent, 4, 4, 8, 8, 0, 0, 0); + CHECK(directMapChild != None, "direct child map setup failed"); + CHECK(GET_WINDOW_STRUCT(directMapChild)->sdlWindow == NULL, + "child window unexpectedly owns an SDL window"); + CHECK(XMapWindow(display, directMapChild), + "mapping child of mapped parent failed"); + CHECK(GET_WINDOW_STRUCT(directMapChild)->mapState == Mapped, + "direct child map did not mark child mapped"); GET_WINDOW_STRUCT(delayedSubChild)->mapState = MapRequested; CHECK(XMapSubwindows(display, delayedSubParent), "XMapSubwindows with map-requested child failed"); @@ -3136,7 +4140,36 @@ static int test_windows(Display *display) CHECK(viewport.x == 5 && viewport.y == 6 && viewport.w == 16 && viewport.h == 16, "child renderer viewport did not use top-left X coordinates"); - XEvent visibility; + SDL_Rect negativeViewport = {.x = -5, .y = -6, .w = 16, .h = 16}; + CHECK(SDL_RenderSetViewport(childRenderer, &negativeViewport) == 0, + "negative viewport setup failed"); + SDL_Rect negativeRead = {.x = 0, .y = 0, .w = 16, .h = 16}; + SDL_Surface *negativeSurface = + getRenderSurfaceRect(childRenderer, &negativeRead); + CHECK(negativeSurface != NULL, + "getRenderSurfaceRect failed for negative viewport"); + SDL_FreeSurface(negativeSurface); + CHECK(SDL_RenderSetViewport(childRenderer, &viewport) == 0, + "viewport restore failed"); + GC childClipGc = XCreateGC(display, parent, 0, NULL); + CHECK(childClipGc != NULL, "ClipByChildren GC creation failed"); + CHECK(XSetForeground(display, childClipGc, 0xFFFFFFFF), + "ClipByChildren parent clear setup failed"); + CHECK(XFillRectangle(display, parent, childClipGc, 0, 0, 32, 32), + "ClipByChildren parent initial fill failed"); + CHECK(XSetForeground(display, childClipGc, 0xFF112233), + "ClipByChildren child draw setup failed"); + CHECK(XFillRectangle(display, child2, childClipGc, 0, 0, 4, 4), + "ClipByChildren child fill failed"); + CHECK(XClearArea(display, parent, 0, 0, 32, 32, False), + "ClipByChildren parent clear failed"); + SDL_Renderer *parentRenderer = getWindowRenderer(parent); + SDL_Surface *clipSurface = getRenderSurface(parentRenderer); + CHECK(clipSurface != NULL, "ClipByChildren readback failed"); + CHECK(pixel_is_rgb(clipSurface, 5, 6, 17, 34, 51), + "parent clear erased mapped child contents"); + SDL_FreeSurface(clipSurface); + XFreeGC(display, childClipGc); CHECK( XCheckTypedWindowEvent(display, child1, VisibilityNotify, &visibility), "VisibilityNotify was not delivered for mapped child"); @@ -3180,6 +4213,10 @@ static int test_windows(Display *display) "child to top-level XReparentWindow failed"); CHECK(expect_map_state(display, mappedChild, IsViewable), "child to top-level reparent did not keep mapped state"); + CHECK(GET_WINDOW_STRUCT(mappedChild)->sdlWindow != NULL, + "child to top-level reparent did not realize an SDL window"); + CHECK(GET_WINDOW_STRUCT(mappedChild)->hasPresented, + "child to top-level reparent did not present before show"); CHECK(XCheckWindowEvent(display, mappedChild, StructureNotifyMask, &reparentEvent), "child to top-level did not post UnmapNotify"); @@ -3293,10 +4330,16 @@ static int test_windows(Display *display) static int test_fonts(Display *display) { - char **names = malloc(sizeof(char *) * 2); + /* The names list passed to XFreeFontInfo must follow the same + * caller-owned, NULL-terminated convention that XListFonts now + * returns: each entry is heap-allocated and the array carries a + * trailing NULL sentinel that XFreeFontNames walks. */ + char **names = malloc(sizeof(char *) * 3); CHECK(names != NULL, "font names allocation failed"); - names[0] = "font-a"; - names[1] = "font-b"; + names[0] = strdup("font-a"); + names[1] = strdup("font-b"); + names[2] = NULL; + CHECK(names[0] != NULL && names[1] != NULL, "font names strdup failed"); XFontStruct *infos = calloc(2, sizeof(XFontStruct)); CHECK(infos != NULL, "font info allocation failed"); @@ -3355,6 +4398,31 @@ static int test_fonts(Display *display) "fixed-size aliases were not listed"); XFreeFontNames(aliases); + int cappedAliasCount = 0; + aliases = XListFonts(display, "9x13", 1, &cappedAliasCount); + CHECK(aliases != NULL && cappedAliasCount == 1, + "XListFonts alias results exceeded maxnames"); + XFreeFontNames(aliases); + + /* XListFonts must not return the caller's pattern buffer as a + * list entry. Mutate the buffer after the call and confirm the + * returned name is unchanged. */ + char patternBuf[64]; + snprintf(patternBuf, sizeof(patternBuf), "-*helv*medium-r-*-12-*"); + int aliasResolved = 0; + char **aliasList = XListFonts(display, patternBuf, 1, &aliasResolved); + if (aliasList != NULL && aliasResolved > 0) { + char first[64]; + snprintf(first, sizeof(first), "%s", aliasList[0]); + memset(patternBuf, 'X', sizeof(patternBuf) - 1); + patternBuf[sizeof(patternBuf) - 1] = '\0'; + CHECK(!strcmp(aliasList[0], first), + "XListFonts aliased the caller's pattern buffer"); + CHECK(!strchr(aliasList[0], '*'), + "XListFonts returned a wildcard pattern as a font name"); + } + XFreeFontNames(aliasList); + XFontStruct *fixed = XLoadQueryFont(display, "fixed"); CHECK(fixed != NULL && fixed->fid != None, "fixed alias did not load"); CHECK(fixed->ascent == 11 && fixed->descent == 2, @@ -3373,6 +4441,11 @@ static int test_fonts(Display *display) "fixed alias XTextWidth16 did not count 16-bit characters"); XFreeFont(display, fixed); + XFontStruct *variable = XLoadQueryFont(display, "variable"); + CHECK(variable != NULL && variable->fid != None, + "variable alias did not load"); + XFreeFont(display, variable); + XFontStruct *sixByThirteen = XLoadQueryFont(display, "6x13"); CHECK(sixByThirteen != NULL && sixByThirteen->fid != None, "6x13 alias did not load"); @@ -3397,6 +4470,91 @@ static int test_fonts(Display *display) "XTextWidth16 ASCII decoding mismatch"); XFreeFont(display, nineByThirteen); + XFontStruct *nineByFifteen = XLoadQueryFont(display, "9x15"); + CHECK(nineByFifteen != NULL && nineByFifteen->fid != None, + "9x15 alias did not load"); + CHECK(XTextWidth(nineByFifteen, "Viola", 5) > 0, + "9x15 alias selected a font without ASCII metrics"); + CHECK(XTextWidth(nineByFifteen, " ", 1) > 0, + "9x15 alias did not provide a usable space width"); + XFreeFont(display, nineByFifteen); + + int motifFixedCount = 0; + char **motifFixedNames = + XListFonts(display, "-misc-fixed-medium-r-*-*-14-*-*-*-*-*-*-*", 1, + &motifFixedCount); + CHECK(motifFixedNames != NULL && motifFixedCount == 1, + "Viola fixed XLFD alias was not listed"); + XFontStruct *motifFixed = XLoadQueryFont(display, motifFixedNames[0]); + CHECK(motifFixed != NULL && motifFixed->fid != None, + "listed Viola fixed XLFD alias did not load"); + CHECK(font_struct_char_width(motifFixed, ' ') > 0, + "listed Viola fixed XLFD alias had zero space width"); + XFreeFont(display, motifFixed); + XFreeFontNames(motifFixedNames); + + int motifHelveticaCount = 0; + char **motifHelveticaNames = XListFonts( + display, "-adobe-helvetica-medium-r-*-*-14-*-*-*-p-*-*-*", 1, + &motifHelveticaCount); + CHECK(motifHelveticaNames != NULL && motifHelveticaCount == 1, + "Viola Helvetica XLFD alias was not listed"); + XFontStruct *motifHelvetica = + XLoadQueryFont(display, motifHelveticaNames[0]); + CHECK(motifHelvetica != NULL && motifHelvetica->fid != None, + "listed Viola Helvetica XLFD alias did not load"); + CHECK(font_struct_char_width(motifHelvetica, ' ') > 0, + "listed Viola Helvetica XLFD alias had zero space width"); + Pixmap motifTextPixmap = XCreatePixmap( + display, RootWindow(display, DefaultScreen(display)), 160, 32, + DefaultDepth(display, DefaultScreen(display))); + CHECK(motifTextPixmap != None, + "Viola Helvetica alias text pixmap creation failed"); + GC motifTextGc = XCreateGC(display, motifTextPixmap, 0, NULL); + CHECK(motifTextGc != NULL, "Viola Helvetica alias GC creation failed"); + CHECK(XSetFont(display, motifTextGc, motifHelvetica->fid), + "Viola Helvetica alias XSetFont failed"); + CHECK(XSetForeground(display, motifTextGc, 0x00FFFFFF), + "Viola Helvetica alias white foreground failed"); + CHECK(XFillRectangle(display, motifTextPixmap, motifTextGc, 0, 0, 160, + 32), + "Viola Helvetica alias background fill failed"); + CHECK(XSetForeground(display, motifTextGc, 0x00000000), + "Viola Helvetica alias black foreground failed"); + last_error_code = 0; + int (*oldErrorHandler)(Display *, XErrorEvent *) = + XSetErrorHandler(record_error); + CHECK(XDrawString(display, motifTextPixmap, motifTextGc, 4, 18, + "World Wide Web", 14), + "Viola Helvetica alias XDrawString failed"); + XSync(display, False); + XSetErrorHandler(oldErrorHandler); + CHECK(last_error_code == 0, + "Viola Helvetica alias XDrawString raised an X error"); + SDL_Renderer *motifTextRenderer = NULL; + GET_RENDERER(motifTextPixmap, motifTextRenderer); + SDL_Surface *motifTextSurface = getRenderSurface(motifTextRenderer); + CHECK(motifTextSurface, + "getRenderSurface for anti-aliased Helvetica text failed"); + int sawAntialiasedEdge = 0; + for (int ty = 0; ty < motifTextSurface->h && !sawAntialiasedEdge; + ty++) { + for (int tx = 0; tx < motifTextSurface->w; tx++) { + if (pixel_is_between_black_and_white(motifTextSurface, tx, + ty)) { + sawAntialiasedEdge = 1; + break; + } + } + } + SDL_FreeSurface(motifTextSurface); + CHECK(sawAntialiasedEdge, + "Viola Helvetica alias text did not render anti-aliased edges"); + XFreeGC(display, motifTextGc); + XFreePixmap(display, motifTextPixmap); + XFreeFont(display, motifHelvetica); + XFreeFontNames(motifHelveticaNames); + XFontStruct *helvetica = XLoadQueryFont(display, "*-helvetica-medium-r-normal--14-*"); if (helvetica) { @@ -3423,13 +4581,51 @@ static int test_fonts(Display *display) "helvetica 140-decipoint XLFD alias lost proportional metrics"); XFreeFont(display, helvetica140); } - int (*timesPreviousErrorHandler)(Display *, XErrorEvent *) = - XSetErrorHandler(ignored_error); + XFontStruct *helvBold = XLoadQueryFont(display, "*helv*bold*-r-*-12-*"); + CHECK(helvBold != NULL, + "Motif helv bold wildcard did not resolve to a fallback font"); + CHECK(helvBold->fid != None, + "Motif helv bold wildcard returned invalid font id"); + CHECK(helvBold->ascent + helvBold->descent >= 12, + "Motif helv bold wildcard fell back to the default font size"); + XFreeFont(display, helvBold); + XFontStruct *times14 = - XLoadQueryFont(display, "*-times-medium-r-normal--14-*-iso8859-1"); - XSetErrorHandler(timesPreviousErrorHandler); - CHECK(times14 == NULL, - "native-missing Times 14 wildcard should not be force-aliased"); + XLoadQueryFont(display, "-*times*medium*-r-*--14-*"); + CHECK(times14 != NULL, + "Motif Times 14 wildcard did not resolve to a fallback font"); + CHECK(times14->fid != None, + "Motif Times 14 wildcard returned invalid font id"); + XFreeFont(display, times14); + + int times14ListedCount = 0; + char **times14Listed = XListFonts(display, "-*times*medium*-r-*--14-*", + 1, ×14ListedCount); + CHECK(times14Listed != NULL && times14ListedCount == 1, + "Motif Times 14 wildcard was not listed"); + CHECK( + !strchr(times14Listed[0], '*') && strstr(times14Listed[0], "--14-"), + "Motif Times 14 listed alias did not preserve requested size"); + times14 = XLoadQueryFont(display, times14Listed[0]); + CHECK(times14 != NULL && times14->fid != None, + "listed Motif Times 14 alias did not load"); + CHECK(times14->ascent + times14->descent >= 14, + "listed Motif Times 14 alias loaded at default size"); + XFreeFont(display, times14); + XFreeFontNames(times14Listed); + + XFontStruct *timesBoldMenu = + XLoadQueryFont(display, "*times*bold*-r-*-14-*"); + CHECK(timesBoldMenu != NULL, + "Motif Times bold menu wildcard did not resolve to a fallback " + "font"); + CHECK(timesBoldMenu->fid != None, + "Motif Times bold menu wildcard returned invalid font id"); + CHECK(timesBoldMenu->ascent + timesBoldMenu->descent >= 14, + "Motif Times bold menu wildcard fell back to the default font " + "size"); + XFreeFont(display, timesBoldMenu); + if (fontCache) { size_t cacheLength = fontCache->length; helvetica = @@ -3462,12 +4658,121 @@ static int test_fonts(Display *display) "XUnloadFont rejected implicit fixed font"); CHECK(XDrawString(display, pixmap, gc, 2, 18, "text", 4), "GC-held font stopped drawing after XUnloadFont"); + CHECK(XSetForeground(display, gc, 0x00FFFFFF), + "font background color setup failed"); + CHECK(XFillRectangle(display, pixmap, gc, 0, 0, 96, 32), + "font background fill failed"); + CHECK(XSetForeground(display, gc, 0), + "font X11 black pixel setup failed"); + CHECK(XDrawString(display, pixmap, gc, 2, 18, "text", 4), + "XDrawString with X11 black pixel failed"); + SDL_Renderer *renderer = NULL; + GET_RENDERER(pixmap, renderer); + SDL_Surface *textSurface = getRenderSurface(renderer); + CHECK(textSurface, "getRenderSurface for X11 black text failed"); + int sawBlackTextPixel = 0; + for (int ty = 0; ty < textSurface->h && !sawBlackTextPixel; ty++) { + for (int tx = 0; tx < textSurface->w; tx++) { + if (pixel_is_rgb(textSurface, tx, ty, 0, 0, 0)) { + sawBlackTextPixel = 1; + break; + } + } + } + SDL_FreeSurface(textSurface); + CHECK(sawBlackTextPixel, + "XDrawString treated X11 black pixel as transparent"); + + Window textWindow = + XCreateSimpleWindow(display, root, 0, 0, 96, 32, 0, 0, 0x00FFFFFF); + CHECK(textWindow != None, "XDrawText window creation failed"); + XMapWindow(display, textWindow); + GC textGc = XCreateGC(display, textWindow, 0, NULL); + CHECK(textGc != NULL, "XDrawText GC creation failed"); + CHECK(XSetForeground(display, textGc, 0), + "XDrawText foreground setup failed"); + Font textFont = XLoadFont(display, "fixed"); + CHECK(textFont != None, "XDrawText font load failed"); + XTextItem textItem = { + .chars = "text", + .nchars = 4, + .delta = 0, + .font = textFont, + }; + CHECK(XDrawText(display, textWindow, textGc, 2, 18, &textItem, 1), + "XDrawText failed"); + GET_RENDERER(textWindow, renderer); + textSurface = getRenderSurface(renderer); + CHECK(textSurface, "getRenderSurface for XDrawText window failed"); + sawBlackTextPixel = 0; + for (int ty = 0; ty < textSurface->h && !sawBlackTextPixel; ty++) { + for (int tx = 0; tx < textSurface->w; tx++) { + if (pixel_is_rgb(textSurface, tx, ty, 0, 0, 0)) { + sawBlackTextPixel = 1; + break; + } + } + } + SDL_FreeSurface(textSurface); + CHECK(sawBlackTextPixel, "XDrawText rendered no visible black pixels"); + XUnloadFont(display, textFont); + XFreeGC(display, textGc); + XDestroyWindow(display, textWindow); + + Pixmap metricPixmap = + XCreatePixmap(display, root, 96, 32, + DefaultDepth(display, DefaultScreen(display))); + CHECK(metricPixmap != None, "fixed metric pixmap creation failed"); + GC metricGc = XCreateGC(display, metricPixmap, 0, NULL); + CHECK(metricGc != NULL, "fixed metric GC creation failed"); + Font metricFont = XLoadFont(display, "fixed"); + CHECK(metricFont != None, "fixed metric font load failed"); + XFontStruct *metricStruct = XQueryFont(display, metricFont); + CHECK(metricStruct != NULL, "fixed metric query failed"); + CHECK(XSetFont(display, metricGc, metricFont), + "fixed metric XSetFont failed"); + CHECK(XSetForeground(display, metricGc, 0x00FFFFFF), + "fixed metric white setup failed"); + CHECK(XFillRectangle(display, metricPixmap, metricGc, 0, 0, 96, 32), + "fixed metric background fill failed"); + CHECK(XSetForeground(display, metricGc, 0x00000000), + "fixed metric black setup failed"); + int metricBaseline = metricStruct->ascent; + CHECK(XDrawString(display, metricPixmap, metricGc, 0, metricBaseline, + "gjpqy", 5), + "fixed metric draw failed"); + CHECK(XSetForeground(display, metricGc, 0x00FFFFFF), + "fixed metric clear color setup failed"); + int metricClearHeight = metricStruct->ascent + metricStruct->descent; + CHECK(XFillRectangle(display, metricPixmap, metricGc, 0, 0, 96, + (unsigned int) metricClearHeight), + "fixed metric row clear failed"); + GET_RENDERER(metricPixmap, renderer); + textSurface = getRenderSurface(renderer); + CHECK(textSurface, "fixed metric readback failed"); + int escapedMetricPixel = 0; + for (int ty = metricClearHeight; + ty < textSurface->h && !escapedMetricPixel; ty++) { + for (int tx = 0; tx < textSurface->w; tx++) { + if (pixel_is_rgb(textSurface, tx, ty, 0, 0, 0)) { + escapedMetricPixel = 1; + break; + } + } + } + SDL_FreeSurface(textSurface); + XFreeFontInfo(NULL, metricStruct, 1); + XUnloadFont(display, metricFont); + XFreeGC(display, metricGc); + XFreePixmap(display, metricPixmap); + CHECK(!escapedMetricPixel, + "fixed font rendered outside its advertised row metrics"); + XGCValues values; CHECK(XGetGCValues(display, gc, GCForeground | GCBackground, &values), "font GC readback failed"); - CHECK( - values.foreground == 0xFF112233 && values.background == 0xFF445566, - "XDrawImageString mutated GC colors"); + CHECK(values.foreground == 0 && values.background == 0xFF445566, + "XDrawImageString mutated GC colors"); XFreeGC(display, gc); XFreePixmap(display, pixmap); @@ -3767,6 +5072,18 @@ static int test_extensions(Display *display) int useMinor = 0; CHECK(XkbUseExtension(display, &useMajor, &useMinor), "XkbUseExtension must report supported"); + + /* XDoubleToFixed must clamp out-of-range and NaN inputs instead of + * invoking undefined behavior on the double->int cast. */ + CHECK(XDoubleToFixed(0.0) == 0, "XDoubleToFixed zero failed"); + CHECK(XDoubleToFixed(1.0) == 0x10000, "XDoubleToFixed one failed"); + CHECK(XDoubleToFixed(-1.0) == -0x10000, "XDoubleToFixed minus one failed"); + CHECK(XDoubleToFixed(1.0e100) == 0x7FFFFFFF, + "XDoubleToFixed positive overflow did not saturate"); + CHECK(XDoubleToFixed(-1.0e100) == (int) (-0x7FFFFFFF - 1), + "XDoubleToFixed negative overflow did not saturate"); + double nanVal = strtod("NaN", NULL); + CHECK(XDoubleToFixed(nanVal) == 0, "XDoubleToFixed NaN did not return 0"); return 1; } @@ -4227,6 +5544,11 @@ static int test_defaults(Display *display) "XBlackPixelOfScreen mismatch"); CHECK(XWhitePixelOfScreen(screen) == WhitePixel(display, screenNumber), "XWhitePixelOfScreen mismatch"); + unsigned long defaultPixelLimit = + 1ul << (unsigned int) DefaultDepth(display, screenNumber); + CHECK(BlackPixel(display, screenNumber) < defaultPixelLimit && + WhitePixel(display, screenNumber) < defaultPixelLimit, + "default black/white pixels exceeded default depth range"); CHECK(XDefaultColormapOfScreen(screen) == DefaultColormap(display, screenNumber), "XDefaultColormapOfScreen mismatch"); diff --git a/tests/test-libxt-micro.c b/tests/test-libxt-micro.c index f63ea1a..1cd883a 100644 --- a/tests/test-libxt-micro.c +++ b/tests/test-libxt-micro.c @@ -15,6 +15,7 @@ * events - XtAppMainLoop drains an XtAppAddTimeOut without spin. * callbacks - XtAddCallback/XtCallCallbacks/XtRemoveCallback round-trip. * gc - XtAllocateGC returns a non-NULL GC and tolerates release. + * pointer - XtGrabPointer accepts same-client regrabs. * resources - XtVaGetValues against an XtAppSetFallbackResources entry. * * The resources path drives the full XrmQGetSearchResource -> @@ -178,6 +179,29 @@ static void test_gc(Widget shell) OK("gc"); } +/* --------------------------------------------------------------- pointer */ + +static void test_pointer(Widget shell) +{ + int status = + XtGrabPointer(shell, True, ButtonPressMask | ButtonReleaseMask, + GrabModeSync, GrabModeAsync, None, None, CurrentTime); + MUST(status == GrabSuccess, "pointer", + "initial XtGrabPointer returned %d, expected GrabSuccess", status); + + /* Motif menu posting can grab the pointer while libXt already owns an + * active pointer grab for the same client. X11 treats this as a regrab + * that updates the active grab, not as AlreadyGrabbed. */ + status = XtGrabPointer(shell, True, ButtonMotionMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime); + MUST(status == GrabSuccess, "pointer", + "same-client XtGrabPointer regrab returned %d, expected GrabSuccess", + status); + + XtUngrabPointer(shell, CurrentTime); + OK("pointer"); +} + /* --------------------------------------------------------- resources */ /* Fallback resources are merged into the per-screen database the first @@ -255,6 +279,7 @@ int main(int argc, char *argv[]) test_events(app); test_callbacks(shell); test_gc(shell); + test_pointer(shell); test_resources(shell); /* Hold off destroy until the shell-using tests finish so a callback diff --git a/tests/test-motif-link.c b/tests/test-motif-link.c index 7b81dfa..d2acef0 100644 --- a/tests/test-motif-link.c +++ b/tests/test-motif-link.c @@ -5,9 +5,12 @@ #include #include #include +#include #include +#include #include #include +#include #include #include "window.h" @@ -60,6 +63,19 @@ static int surface_has_visible_pixels(SDL_Surface *surface) } static int button_activations = 0; +static int menu_item_presses = 0; +static int menu_item_releases = 0; +static int menu_item_motions = 0; +static int menu_item_enters = 0; +static int menu_item_arms = 0; +static int menu_item_disarms = 0; +static unsigned int menu_item_last_press_state = 0; +static unsigned int menu_item_last_release_state = 0; +static unsigned int menu_item_last_button = 0; +static Time menu_item_last_press_time = 0; +static Time menu_item_last_release_time = 0; +static int menu_item_last_x = 0; +static int menu_item_last_y = 0; static int looks_like_allocated_xid(Window window) { @@ -76,6 +92,56 @@ static void activate_cb(Widget widget, button_activations++; } +static void menu_arm_cb(Widget widget, + XtPointer client_data, + XtPointer call_data) +{ + (void) widget; + (void) client_data; + (void) call_data; + menu_item_arms++; +} + +static void menu_disarm_cb(Widget widget, + XtPointer client_data, + XtPointer call_data) +{ + (void) widget; + (void) client_data; + (void) call_data; + menu_item_disarms++; +} + +static void menu_item_event_cb(Widget widget, + XtPointer client_data, + XEvent *event, + Boolean *continue_to_dispatch) +{ + (void) widget; + (void) client_data; + (void) continue_to_dispatch; + if (event->type == ButtonPress) + menu_item_presses++; + else if (event->type == ButtonRelease) + menu_item_releases++; + else if (event->type == MotionNotify) + menu_item_motions++; + else if (event->type == EnterNotify) + menu_item_enters++; + if (event->type == ButtonPress || event->type == ButtonRelease) { + menu_item_last_button = event->xbutton.button; + menu_item_last_x = event->xbutton.x; + menu_item_last_y = event->xbutton.y; + if (event->type == ButtonPress) { + menu_item_last_press_state = event->xbutton.state; + menu_item_last_press_time = event->xbutton.time; + } else { + menu_item_last_release_state = event->xbutton.state; + menu_item_last_release_time = event->xbutton.time; + } + } +} + static int dispatch_pending(XtAppContext app) { int dispatched = 0; @@ -102,15 +168,14 @@ static Widget sdl_shell_for_widget(Widget widget) return NULL; } -static int click_widget(XtAppContext app, Widget target) +static int widget_center_in_sdl_window(Widget target, + Uint32 *windowID, + int *shellX, + int *shellY) { if (!target) return 0; - for (int i = 0; i < 50 && !XtIsRealized(target); i++) { - dispatch_pending(app); - SDL_Delay(1); - } if (!XtIsRealized(target)) return 0; @@ -142,16 +207,32 @@ static int click_widget(XtAppContext app, Widget target) Dimension width = 0, height = 0; XtVaGetValues(target, XmNwidth, &width, XmNheight, &height, NULL); - int shellX = 0, shellY = 0; Window child = None; if (!XTranslateCoordinates( display, targetWindow, shellWindow, localX + (int) width / 2, - localY + (int) height / 2, &shellX, &shellY, &child)) { + localY + (int) height / 2, shellX, shellY, &child)) { return 0; } - Uint32 windowID = - SDL_GetWindowID(GET_WINDOW_STRUCT(shellWindow)->sdlWindow); + *windowID = SDL_GetWindowID(GET_WINDOW_STRUCT(shellWindow)->sdlWindow); + return 1; +} + +static int click_widget_until(XtAppContext app, Widget target, int *counter) +{ + /* Generous wait windows: tests run under CI alongside the differential + * screenshot harness, where Xt realization can take 50-100ms under + * load. Tighter 20-50ms loops occasionally flaked. */ + for (int i = 0; i < 200 && target && !XtIsRealized(target); i++) { + dispatch_pending(app); + SDL_Delay(1); + } + + Uint32 windowID = 0; + int shellX = 0, shellY = 0; + if (!widget_center_in_sdl_window(target, &windowID, &shellX, &shellY)) + return 0; + SDL_Event event; SDL_zero(event); event.type = SDL_MOUSEBUTTONDOWN; @@ -161,6 +242,11 @@ static int click_widget(XtAppContext app, Widget target) event.button.y = shellY; SDL_PushEvent(&event); + for (int i = 0; i < 10; i++) { + dispatch_pending(app); + SDL_Delay(1); + } + SDL_zero(event); event.type = SDL_MOUSEBUTTONUP; event.button.windowID = windowID; @@ -169,10 +255,152 @@ static int click_widget(XtAppContext app, Widget target) event.button.y = shellY; SDL_PushEvent(&event); - for (int i = 0; i < 20 && button_activations == 0; i++) { + if (counter) { + for (int i = 0; i < 200 && *counter == 0; i++) { + dispatch_pending(app); + SDL_Delay(1); + } + return *counter > 0; + } + /* No counter to wait on: drain just enough cycles to deliver the + * button-up and let the caller assert the real post-condition (e.g. + * XtIsRealized on the popped menu). */ + for (int i = 0; i < 10; i++) { + dispatch_pending(app); + SDL_Delay(1); + } + return 1; +} + +static int click_widget(XtAppContext app, Widget target) +{ + return click_widget_until(app, target, &button_activations); +} + +static int move_pointer_to_widget(XtAppContext app, Widget target) +{ + Uint32 windowID = 0; + int shellX = 0, shellY = 0; + if (!widget_center_in_sdl_window(target, &windowID, &shellX, &shellY)) + return 0; + + SDL_Event event; + SDL_zero(event); + event.type = SDL_MOUSEMOTION; + event.motion.windowID = windowID; + event.motion.state = 0; + event.motion.x = shellX; + event.motion.y = shellY; + SDL_PushEvent(&event); + + for (int i = 0; i < 100; i++) { + dispatch_pending(app); + SDL_Delay(1); + } + return 1; +} + +static int menu_click_post_select(XtAppContext app, Widget cascade, Widget item) +{ + /* The cascade has no observable Xt callback we can wait on; rely on + * the XtIsRealized(item) check below as the real post-condition. */ + if (!click_widget_until(app, cascade, NULL)) { + fprintf(stderr, "Motif Help cascade had no SDL-backed center point\n"); + return 0; + } + for (int i = 0; i < 200 && !XtIsRealized(item); i++) { dispatch_pending(app); SDL_Delay(1); } + if (!XtIsRealized(item)) { + fprintf(stderr, "Motif Help menu item was not realized after click\n"); + return 0; + } + SDL_Delay(500); + dispatch_pending(app); + if (!move_pointer_to_widget(app, item)) { + fprintf(stderr, "Motif Help menu item did not accept pointer motion\n"); + return 0; + } + return click_widget(app, item); +} + +static int menu_drag_select(XtAppContext app, Widget cascade, Widget item) +{ + Uint32 cascadeWindowID = 0; + int cascadeX = 0, cascadeY = 0; + if (!widget_center_in_sdl_window(cascade, &cascadeWindowID, &cascadeX, + &cascadeY)) { + fprintf(stderr, "Motif Help cascade has no SDL-backed center point\n"); + return 0; + } + + SDL_Event event; + SDL_zero(event); + event.type = SDL_MOUSEBUTTONDOWN; + event.button.windowID = cascadeWindowID; + event.button.button = SDL_BUTTON_LEFT; + event.button.x = cascadeX; + event.button.y = cascadeY; + SDL_PushEvent(&event); + + for (int i = 0; i < 200 && !XtIsRealized(item); i++) { + dispatch_pending(app); + SDL_Delay(1); + } + dispatch_pending(app); + if (!XtIsRealized(item)) { + fprintf(stderr, "Motif Help menu item was not realized after press\n"); + return 0; + } + + Uint32 itemWindowID = 0; + int itemX = 0, itemY = 0; + if (!widget_center_in_sdl_window(item, &itemWindowID, &itemX, &itemY)) { + fprintf(stderr, + "Motif Help menu item has no SDL-backed center point\n"); + return 0; + } + + SDL_zero(event); + event.type = SDL_MOUSEMOTION; + event.motion.windowID = itemWindowID; + event.motion.state = SDL_BUTTON_LMASK; + event.motion.x = itemX; + event.motion.y = itemY; + SDL_PushEvent(&event); + + for (int i = 0; i < 100 && menu_item_arms == 0; i++) { + dispatch_pending(app); + SDL_Delay(1); + } + + SDL_zero(event); + event.type = SDL_MOUSEBUTTONUP; + event.button.windowID = itemWindowID; + event.button.button = SDL_BUTTON_LEFT; + event.button.x = itemX; + event.button.y = itemY; + SDL_PushEvent(&event); + + for (int i = 0; i < 200 && button_activations == 0; i++) { + dispatch_pending(app); + SDL_Delay(1); + } + if (button_activations == 0) { + XWindowAttributes attrs; + memset(&attrs, 0, sizeof(attrs)); + int mapped = + XGetWindowAttributes(XtDisplay(item), XtWindow(item), &attrs) + ? attrs.map_state + : -1; + fprintf(stderr, + "Motif Help menu item realized=%d mapped=%d activation=%d " + "press=%d release=%d motion=%d enter=%d\n", + XtIsRealized(item), mapped, button_activations, + menu_item_presses, menu_item_releases, menu_item_motions, + menu_item_enters); + } return button_activations > 0; } @@ -253,6 +481,109 @@ int main(int argc, char **argv) return 1; } + XtUnmanageChild(button); + + Widget mainWindow = + XmCreateMainWindow(shell, (char *) "mainWindow", NULL, 0); + Widget menuBar = + mainWindow ? XmCreateMenuBar(mainWindow, (char *) "menuBar", NULL, 0) + : NULL; + Widget helpMenu = + menuBar ? XmCreatePulldownMenu(menuBar, (char *) "helpMenu", NULL, 0) + : NULL; + Widget helpCascade = + menuBar ? XmCreateCascadeButton(menuBar, (char *) "Help", NULL, 0) + : NULL; + Widget helpItem = + helpMenu ? XmCreatePushButton(helpMenu, (char *) "Help Item", NULL, 0) + : NULL; + XmString helpLabel = XmStringCreateLocalized((char *) "Help"); + XmString helpItemLabel = XmStringCreateLocalized((char *) "Help Item"); + if (!mainWindow || !menuBar || !helpMenu || !helpCascade || !helpItem || + !helpLabel || !helpItemLabel) { + fprintf(stderr, "failed to create Motif Help menu resources\n"); + if (helpLabel) + XmStringFree(helpLabel); + if (helpItemLabel) + XmStringFree(helpItemLabel); + XtDestroyWidget(shell); + return 1; + } + + XtVaSetValues(helpCascade, XmNlabelString, helpLabel, XmNsubMenuId, + helpMenu, NULL); + XtVaSetValues(helpItem, XmNlabelString, helpItemLabel, NULL); + XtVaSetValues(menuBar, XmNmenuHelpWidget, helpCascade, NULL); + XmStringFree(helpLabel); + XmStringFree(helpItemLabel); + XtAddCallback(helpItem, XmNactivateCallback, activate_cb, NULL); + XtAddCallback(helpItem, XmNarmCallback, menu_arm_cb, NULL); + XtAddCallback(helpItem, XmNdisarmCallback, menu_disarm_cb, NULL); + XtAddEventHandler(helpItem, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask | + EnterWindowMask, + False, menu_item_event_cb, NULL); + XtManageChild(helpItem); + XtManageChild(helpCascade); + XtManageChild(menuBar); + XmMainWindowSetAreas(mainWindow, menuBar, NULL, NULL, NULL, NULL); + XtManageChild(mainWindow); + dispatch_pending(app); + XSync(display, False); + + button_activations = 0; + menu_item_presses = 0; + menu_item_releases = 0; + menu_item_motions = 0; + menu_item_enters = 0; + menu_item_arms = 0; + menu_item_disarms = 0; + menu_item_last_press_time = 0; + menu_item_last_release_time = 0; + if (!menu_click_post_select(app, helpCascade, helpItem)) { + fprintf(stderr, + "Motif Help menu item did not activate after click-post " + "press=%d release=%d motion=%d enter=%d arm=%d disarm=%d " + "button=%u " + "press_state=0x%x release_state=0x%x time=(%lu,%lu) " + "xy=(%d,%d)\n", + menu_item_presses, menu_item_releases, menu_item_motions, + menu_item_enters, menu_item_arms, menu_item_disarms, + menu_item_last_button, menu_item_last_press_state, + menu_item_last_release_state, + (unsigned long) menu_item_last_press_time, + (unsigned long) menu_item_last_release_time, menu_item_last_x, + menu_item_last_y); + XtDestroyWidget(shell); + return 1; + } + + button_activations = 0; + menu_item_presses = 0; + menu_item_releases = 0; + menu_item_motions = 0; + menu_item_enters = 0; + menu_item_arms = 0; + menu_item_disarms = 0; + if (!menu_drag_select(app, helpCascade, helpItem)) { + fprintf(stderr, + "Motif Help menu item did not activate from menu flow " + "press=%d release=%d motion=%d enter=%d arm=%d disarm=%d " + "button=%u press_state=0x%x release_state=0x%x " + "time=(%lu,%lu) xy=(%d,%d)\n", + menu_item_presses, menu_item_releases, menu_item_motions, + menu_item_enters, menu_item_arms, menu_item_disarms, + menu_item_last_button, menu_item_last_press_state, + menu_item_last_release_state, + (unsigned long) menu_item_last_press_time, + (unsigned long) menu_item_last_release_time, menu_item_last_x, + menu_item_last_y); + XtDestroyWidget(shell); + return 1; + } + + XtUnmanageChild(mainWindow); + button_activations = 0; XmString message = XmStringCreateLocalized((char *) "Select a language, Verify your OS"); @@ -278,7 +609,7 @@ int main(int argc, char **argv) Widget ok = XtNameToWidget(dialog, (char *) "OK"); XtManageChild(dialog); XtPopup(XtParent(dialog), XtGrabNone); - for (int i = 0; i < 20 && !XtIsRealized(ok); i++) { + for (int i = 0; i < 200 && !XtIsRealized(ok); i++) { dispatch_pending(app); SDL_Delay(1); } diff --git a/tests/test-xtest.c b/tests/test-xtest.c new file mode 100644 index 0000000..7473426 --- /dev/null +++ b/tests/test-xtest.c @@ -0,0 +1,353 @@ +/* In-tree regression for the XTest fake-event path in src/xtest.c. + * + * Opens a display, maps a top-level window with mouse + key event masks, + * then drives synthetic events via XTestFakeMotionEvent / + * XTestFakeButtonEvent / XTestFakeKeyEvent and asserts the matching X + * events come back out of the queue. Without this test the XTest path + * is exercised only by ViolaWWW + the replay engine, neither of which + * runs from make check. + * + * Runs under SDL_VIDEODRIVER=dummy so it works on headless CI. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "replay-target.h" + +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "test-xtest FAIL: %s (%s:%d)\n", msg, __FILE__, \ + __LINE__); \ + exit(1); \ + } \ + } while (0) + +/* Pull count events from the queue, waiting on each one via XNextEvent. + * Returns the number of events of wanted_type that appeared. */ +static int drain_until(Display *dpy, int wanted_type, int max_iterations) +{ + int hits = 0; + for (int i = 0; i < max_iterations; i++) { + XEvent ev; + if (XPending(dpy) == 0) + break; + XNextEvent(dpy, &ev); + if (ev.type == wanted_type) + hits++; + } + return hits; +} + +static XEvent next_event_of_type(Display *dpy, + int wanted_type, + int max_iterations, + const char *msg) +{ + for (int i = 0; i < max_iterations && XPending(dpy) > 0; i++) { + XEvent ev; + XNextEvent(dpy, &ev); + if (ev.type == wanted_type) + return ev; + } + CHECK(0, msg); + XEvent never; + memset(&never, 0, sizeof(never)); + return never; +} + +static void expect_connection_readable(Display *dpy, const char *msg) +{ + fd_set fds; + FD_ZERO(&fds); + int fd = ConnectionNumber(dpy); + FD_SET(fd, &fds); + struct timeval tv = {0, 200000}; + int rc = select(fd + 1, &fds, NULL, NULL, &tv); + CHECK(rc > 0 && FD_ISSET(fd, &fds), msg); +} + +typedef struct { + Display *dpy; + int screen; + int motion_ok; + int press_ok; + int release_ok; +} AsyncFakeClickArgs; + +static void *async_fake_click(void *opaque) +{ + AsyncFakeClickArgs *args = opaque; + args->motion_ok = + XTestFakeMotionEvent(args->dpy, args->screen, 130, 145, 0); + args->press_ok = XTestFakeButtonEvent(args->dpy, Button1, True, 0); + args->release_ok = XTestFakeButtonEvent(args->dpy, Button1, False, 0); + return NULL; +} + +int main(void) +{ + Display *dpy = XOpenDisplay(NULL); + CHECK(dpy != NULL, "XOpenDisplay"); + + /* XTest must advertise itself once the display is open. */ + int opcode = 0, evt = 0, err = 0, major = 0, minor = 0; + CHECK(XQueryExtension(dpy, XTestExtensionName, &opcode, &evt, &err), + "XQueryExtension advertises XTEST"); + CHECK(opcode != 0, "XTEST generic opcode is non-zero"); + CHECK(XTestQueryExtension(dpy, &evt, &err, &major, &minor), + "XTestQueryExtension"); + CHECK(major >= 2, "XTest major version >= 2"); + + int screen = DefaultScreen(dpy); + Window root = RootWindow(dpy, screen); + Window win = + XCreateSimpleWindow(dpy, root, 70, 80, 320, 240, 0, + BlackPixel(dpy, screen), WhitePixel(dpy, screen)); + CHECK(win != None, "XCreateSimpleWindow"); + XSelectInput(dpy, win, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask | + KeyPressMask | KeyReleaseMask | ExposureMask); + XMapWindow(dpy, win); + XSync(dpy, False); + + /* Drain the initial Expose / MapNotify burst so the assertions below + * count only XTest-driven events. */ + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + } + + /* Button-only callers should click at the current pointer location, not at + * the zero-initialized fake-motion cache. */ + Uint32 target_id = replayTargetWindowId(); + CHECK(target_id != 0, "XTest cached target window id"); + SDL_Window *target_sdl_window = SDL_GetWindowFromID(target_id); + CHECK(target_sdl_window != NULL, "XTest cached target SDL window"); + SDL_WarpMouseInWindow(target_sdl_window, 40, 50); + SDL_PumpEvents(); + int mouse_x = 0, mouse_y = 0; + SDL_GetMouseState(&mouse_x, &mouse_y); + CHECK(mouse_x == 40 && mouse_y == 50, + "test setup moved SDL pointer to window-local 40,50"); + CHECK(XTestFakeButtonEvent(dpy, 1, True, 0) == 1, + "button-only XTestFakeButtonEvent press"); + CHECK(XTestFakeButtonEvent(dpy, 1, False, 0) == 1, + "button-only XTestFakeButtonEvent release"); + XSync(dpy, False); + XEvent button_only_press = + next_event_of_type(dpy, ButtonPress, 32, "button-only ButtonPress"); + XEvent button_only_release = + next_event_of_type(dpy, ButtonRelease, 32, "button-only ButtonRelease"); + CHECK(button_only_press.xbutton.window == win, + "button-only ButtonPress routed to test window"); + CHECK(button_only_release.xbutton.window == win, + "button-only ButtonRelease routed to test window"); + CHECK(button_only_press.xbutton.x_root == 110, + "button-only ButtonPress keeps current root x"); + CHECK(button_only_press.xbutton.y_root == 130, + "button-only ButtonPress keeps current root y"); + CHECK(button_only_press.xbutton.x == 40, + "button-only ButtonPress keeps current window-local x"); + CHECK(button_only_press.xbutton.y == 50, + "button-only ButtonPress keeps current window-local y"); + + AsyncFakeClickArgs async_args = {dpy, screen, 0, 0, 0}; + pthread_t thread; + CHECK(pthread_create(&thread, NULL, async_fake_click, &async_args) == 0, + "pthread_create async XTest click"); + CHECK(pthread_join(thread, NULL) == 0, "pthread_join async XTest click"); + CHECK(async_args.motion_ok == 1 && async_args.press_ok == 1 && + async_args.release_ok == 1, + "async XTest fake calls returned success"); + expect_connection_readable( + dpy, + "async XTest push woke the X connection fd before event conversion"); + XEvent async_motion = + next_event_of_type(dpy, MotionNotify, 32, "async MotionNotify"); + XEvent async_press = + next_event_of_type(dpy, ButtonPress, 32, "async ButtonPress"); + XEvent async_release = + next_event_of_type(dpy, ButtonRelease, 32, "async ButtonRelease"); + CHECK(async_motion.xmotion.x_root == 130, + "async MotionNotify keeps root x"); + CHECK(async_motion.xmotion.y_root == 145, + "async MotionNotify keeps root y"); + CHECK(async_press.xbutton.x_root == 130, "async ButtonPress keeps root x"); + CHECK(async_press.xbutton.y_root == 145, "async ButtonPress keeps root y"); + CHECK(async_release.xbutton.x_root == 130, + "async ButtonRelease keeps root x"); + CHECK(async_release.xbutton.y_root == 145, + "async ButtonRelease keeps root y"); + + CHECK(XTestFakeRelativeMotionEvent(dpy, 5, -3, 0) == 1, + "relative-only XTestFakeRelativeMotionEvent"); + XSync(dpy, False); + XEvent relative_motion = + next_event_of_type(dpy, MotionNotify, 32, "relative-only MotionNotify"); + CHECK(relative_motion.xmotion.window == win, + "relative-only MotionNotify routed to test window"); + CHECK(relative_motion.xmotion.x_root == 135, + "relative-only MotionNotify keeps current-plus-delta root x"); + CHECK(relative_motion.xmotion.y_root == 142, + "relative-only MotionNotify keeps current-plus-delta root y"); + CHECK(relative_motion.xmotion.x == 65, + "relative-only MotionNotify keeps current-plus-delta local x"); + CHECK(relative_motion.xmotion.y == 62, + "relative-only MotionNotify keeps current-plus-delta local y"); + + /* MotionNotify path. */ + CHECK(XTestFakeMotionEvent(dpy, screen, 100, 120, 0) == 1, + "XTestFakeMotionEvent return"); + XSync(dpy, False); + XEvent motion = next_event_of_type(dpy, MotionNotify, 16, + "MotionNotify reached the queue"); + CHECK(motion.xmotion.window == win, "MotionNotify routed to test window"); + CHECK(motion.xmotion.x_root == 100, "MotionNotify keeps root x"); + CHECK(motion.xmotion.y_root == 120, "MotionNotify keeps root y"); + CHECK(motion.xmotion.x == 30, "MotionNotify converts to window-local x"); + CHECK(motion.xmotion.y == 40, "MotionNotify converts to window-local y"); + + /* ButtonPress / ButtonRelease pair, button 1. */ + CHECK(XTestFakeButtonEvent(dpy, 1, True, 0) == 1, + "XTestFakeButtonEvent press"); + CHECK(XTestFakeButtonEvent(dpy, 1, False, 0) == 1, + "XTestFakeButtonEvent release"); + XSync(dpy, False); + + XEvent press = + next_event_of_type(dpy, ButtonPress, 32, "ButtonPress arrived"); + XEvent release = + next_event_of_type(dpy, ButtonRelease, 32, "ButtonRelease arrived"); + CHECK(press.xbutton.window == win, "ButtonPress routed to test window"); + CHECK(release.xbutton.window == win, "ButtonRelease routed to test window"); + CHECK(press.xbutton.x_root == 100, "ButtonPress keeps root x"); + CHECK(press.xbutton.y_root == 120, "ButtonPress keeps root y"); + CHECK(press.xbutton.x == 30, "ButtonPress converts to window-local x"); + CHECK(press.xbutton.y == 40, "ButtonPress converts to window-local y"); + CHECK(release.xbutton.x_root == 100, "ButtonRelease keeps root x"); + CHECK(release.xbutton.y_root == 120, "ButtonRelease keeps root y"); + CHECK(release.xbutton.x == 30, "ButtonRelease converts to window-local x"); + CHECK(release.xbutton.y == 40, "ButtonRelease converts to window-local y"); + + /* KeyPress path. The exact keysym mapping is host-locale-dependent in + * the SDL_GetKeyFromScancode path; assert only that *some* KeyPress + * shows up for a non-zero code so a future regression that drops the + * push entirely would still fail this test. */ + CHECK(XTestFakeKeyEvent(dpy, 'a', True, 0) == 1, "XTestFakeKeyEvent press"); + CHECK(XTestFakeKeyEvent(dpy, 'a', False, 0) == 1, + "XTestFakeKeyEvent release"); + XSync(dpy, False); + int keys = drain_until(dpy, KeyPress, 32); + CHECK(keys > 0, "KeyPress arrived"); + + XUnmapWindow(dpy, win); + XSync(dpy, False); + CHECK(replayTargetWindowId() != target_id, + "XUnmapWindow retired cached XTest target"); + XMapWindow(dpy, win); + XSync(dpy, False); + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + } + target_id = replayTargetWindowId(); + CHECK(target_id != 0, "XTest re-cached target after remap"); + + /* Target re-adoption after retire: XDestroyWindow the big window, then map + * a small popup followed by a similarly-sized main window. The high + * water mark floor in replayTargetOfferWindow must reject the popup so + * the subsequent main wins the cache, instead of the popup grabbing + * the slot just because it raced the new main into the queue. This + * is the gap surfaced by check-smoke-motif: when fileview's + * language dialog dismisses, several Motif drag popups (10x10 + * override-redirect) map alongside the new XmMainWindow and would + * otherwise become the smoke target. */ + XDestroyWindow(dpy, win); + XSync(dpy, False); + CHECK(replayTargetWindowId() != target_id, + "XDestroyWindow retired cached XTest target"); + /* Pump events so the destroyed-window cleanup propagates through the + * queue before mapping replacement windows. */ + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + } + /* Tiny popup-shaped window (would normally land first). */ + Window popup = + XCreateSimpleWindow(dpy, root, 0, 0, 10, 10, 0, BlackPixel(dpy, screen), + WhitePixel(dpy, screen)); + XSetWindowAttributes attrs; + attrs.override_redirect = True; + XChangeWindowAttributes(dpy, popup, CWOverrideRedirect, &attrs); + XMapWindow(dpy, popup); + XSync(dpy, False); + /* Same-size successor "main" window. */ + Window successor = + XCreateSimpleWindow(dpy, root, 0, 0, 320, 240, 0, + BlackPixel(dpy, screen), WhitePixel(dpy, screen)); + XSelectInput(dpy, successor, + ButtonPressMask | ButtonReleaseMask | PointerMotionMask); + XMapWindow(dpy, successor); + XSync(dpy, False); + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + } + CHECK(XTestFakeButtonEvent(dpy, 1, True, 0) == 1, + "post-retire cached-position ButtonPress returned 1"); + CHECK(XTestFakeButtonEvent(dpy, 1, False, 0) == 1, + "post-retire cached-position ButtonRelease returned 1"); + XSync(dpy, False); + XEvent successor_press = + next_event_of_type(dpy, ButtonPress, 32, + "post-retire cached-position ButtonPress arrived"); + CHECK(successor_press.xbutton.window == successor, + "post-retire cached-position ButtonPress routed to successor"); + CHECK(successor_press.xbutton.x_root == 100, + "post-retire cached-position ButtonPress preserved root x"); + CHECK(successor_press.xbutton.y_root == 120, + "post-retire cached-position ButtonPress preserved root y"); + CHECK(successor_press.xbutton.x == 100, + "post-retire cached-position ButtonPress rebased local x"); + CHECK(successor_press.xbutton.y == 120, + "post-retire cached-position ButtonPress rebased local y"); + + /* Fire a synthetic motion and confirm we get a MotionNotify on the + * successor and not on the popup. The popup did not select + * PointerMotionMask, so even if xtest accidentally targeted it the + * event would be silently dropped instead of arriving here. */ + CHECK(XTestFakeMotionEvent(dpy, screen, 80, 90, 0) == 1, + "post-retire fake motion returned 1"); + XSync(dpy, False); + int successorMotions = 0; + for (int i = 0; i < 16 && XPending(dpy) > 0; i++) { + XEvent ev; + XNextEvent(dpy, &ev); + if (ev.type == MotionNotify && ev.xmotion.window == successor) + successorMotions++; + } + CHECK(successorMotions >= 1, + "post-retire motion routed to the successor (high-water re-adopt)"); + XDestroyWindow(dpy, popup); + XDestroyWindow(dpy, successor); + XSync(dpy, False); + + /* Idempotence: a fake event after the target window is destroyed + * must not fault, and XTestForgetTargetWindow inside libx11-compat + * unrealizeTopLevelWindow should leave subsequent calls inert. */ + int rc = XTestFakeMotionEvent(dpy, screen, 0, 0, 0); + CHECK(rc == 0 || rc == 1, "post-destroy fake call did not crash"); + + XCloseDisplay(dpy); + printf("test-xtest: ok\n"); + return 0; +} diff --git a/tests/ui/assertions/common-visible.json b/tests/ui/assertions/common-visible.json new file mode 100644 index 0000000..c3b3630 --- /dev/null +++ b/tests/ui/assertions/common-visible.json @@ -0,0 +1,10 @@ +{ + "assertions": [ + { + "type": "non_empty" + }, + { + "type": "not_all_black" + } + ] +} diff --git a/tests/ui/assertions/motif-wsm-labels.json b/tests/ui/assertions/motif-wsm-labels.json new file mode 100644 index 0000000..5729751 --- /dev/null +++ b/tests/ui/assertions/motif-wsm-labels.json @@ -0,0 +1,9 @@ +{ + "assertions": [ + { + "type": "region_non_background", + "rect": [0, 0, 900, 700], + "min_dark_ratio": 0.001 + } + ] +} diff --git a/tests/ui/assertions/violawww-help-menu.json b/tests/ui/assertions/violawww-help-menu.json new file mode 100644 index 0000000..15a374a --- /dev/null +++ b/tests/ui/assertions/violawww-help-menu.json @@ -0,0 +1,20 @@ +{ + "_comment": "Coordinates are display-space inside the 1024x720 capture. The Help popup is an override-redirect top-level created near x=754,y=29 after Motif resolves its Times bold 14 menu font; display_rect is scaled to the actual image size so Retina captures and 1x captures use the same rule.", + "assertions": [ + { + "type": "changed_region", + "baseline": "initial", + "display_size": [1024, 720], + "display_rect": [754, 29, 160, 94], + "min_changed_ratio": 0.35 + }, + { + "_comment": "Threshold bumped from 0.08 to 0.10 because CI consistently measured 0.08564 (same value across reruns, not flake). The popup region carries Motif's Times-bold-14 menu labels on a light background, which sits in the 8-9% dark-pixel range under Xvfb's font hinting. The previous 0.08 left no headroom for font-rendering deltas; 0.10 still catches a real popup-paint regression (e.g. the menu drawing as a solid dark block ~50%+) without flaking on tooling variance.", + "type": "region_max_dark_ratio", + "display_size": [1024, 720], + "display_rect": [754, 29, 160, 94], + "dark_threshold": 32, + "max_dark_ratio": 0.10 + } + ] +} diff --git a/tests/ui/assertions/violawww-scroll-changed.json b/tests/ui/assertions/violawww-scroll-changed.json new file mode 100644 index 0000000..ed91ba1 --- /dev/null +++ b/tests/ui/assertions/violawww-scroll-changed.json @@ -0,0 +1,11 @@ +{ + "_comment": "Compares the post-scroll capture against the initial frame inside the 800x720 vw viewport. min_changed_ratio is generous: any honest scroll moves at least a few percent of the document pixels even on tiny fixtures.", + "assertions": [ + { + "type": "changed_region", + "baseline": "initial", + "rect": [40, 130, 720, 560], + "min_changed_ratio": 0.02 + } + ] +} diff --git a/tests/ui/assertions/violawww-scroll-resized-changed.json b/tests/ui/assertions/violawww-scroll-resized-changed.json new file mode 100644 index 0000000..1044d0f --- /dev/null +++ b/tests/ui/assertions/violawww-scroll-resized-changed.json @@ -0,0 +1,11 @@ +{ + "_comment": "Compares the resized capture against the initial frame with a crop that is in-bounds for the 640px resized vw window.", + "assertions": [ + { + "type": "changed_region", + "baseline": "initial", + "rect": [40, 130, 600, 560], + "min_changed_ratio": 0.02 + } + ] +} diff --git a/tests/ui/assertions/violawww-scroll-resized.json b/tests/ui/assertions/violawww-scroll-resized.json new file mode 100644 index 0000000..5fda57d --- /dev/null +++ b/tests/ui/assertions/violawww-scroll-resized.json @@ -0,0 +1,23 @@ +{ + "_comment": "Resized vw captures are 640px wide, so this crop stays in-bounds while covering the visible document body. Clean resize frames should stay below 50 dense rows. min_dark_ratio is intentionally lower than the scrolled-state threshold because the compat layer's post-resize redraw of ViolaWWW's document viewport is incomplete on Xvfb (the compat/violawww-patches/clear-viola-target-after-resize.patch covers the common case but the post-resize frame still drops most rendered text); the assertion catches a fully-blank gray viewport (which would be 0.0000) while accepting the partial redraw we currently produce. Tighten once the resize redraw path lands a full fix.", + "assertions": [ + { + "type": "non_empty" + }, + { + "type": "not_all_black" + }, + { + "type": "region_non_background", + "rect": [40, 255, 600, 435], + "min_dark_ratio": 0.003 + }, + { + "type": "stale_text", + "rect": [40, 255, 600, 435], + "dark_threshold": 80, + "max_row_dark_ratio": 0.21, + "max_dense_rows": 50 + } + ] +} diff --git a/tests/ui/assertions/violawww-scroll.json b/tests/ui/assertions/violawww-scroll.json new file mode 100644 index 0000000..d03b6ea --- /dev/null +++ b/tests/ui/assertions/violawww-scroll.json @@ -0,0 +1,23 @@ +{ + "_comment": "Rect coordinates are window-relative inside the 800x720 vw frame. The viewport rect skips Motif chrome (menubar, address, toolbar) so stale-text ratios are computed only over the rendered HTML body. Clean scroll frames measure below 50 dense rows; double-stamped stale glyphs push the count much higher.", + "assertions": [ + { + "type": "non_empty" + }, + { + "type": "not_all_black" + }, + { + "type": "region_non_background", + "rect": [40, 255, 720, 435], + "min_dark_ratio": 0.01 + }, + { + "type": "stale_text", + "rect": [40, 255, 720, 435], + "dark_threshold": 80, + "max_row_dark_ratio": 0.21, + "max_dense_rows": 50 + } + ] +} diff --git a/tests/ui/fixtures/violawww-scroll.html b/tests/ui/fixtures/violawww-scroll.html new file mode 100644 index 0000000..e560e0b --- /dev/null +++ b/tests/ui/fixtures/violawww-scroll.html @@ -0,0 +1,63 @@ + + + +ViolaWWW replay scroll fixture + + +

ViolaWWW Replay Scroll Fixture

+

This page intentionally contains repeated fixed-size text rows so +scrolling exposes stale-pixel and text-metric regressions.

+

Section 1 | +Section 8 | +Section 16

+
+

Section 1

+

gjpqy fixed font descenders should clear cleanly after every scroll.

+

WWW browser text should not duplicate, overlap, or smear on expose.

+

Section 2

+

Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda.

+

Repeated rows make stale glyph fragments visually obvious.

+

Section 3

+

Common Library, Line Mode Browser, MidasWWW, and ViolaWWW.

+

Each line gives the scroll region more repaint work.

+

Section 4

+

Characters with descenders: g j p q y, again and again.

+

Numbers and punctuation: 0123456789 ! ? / : ; - _ + =.

+

Section 5

+

Longer text wraps across the client area and exercises row clearing.

+

The smoke test does not require native X11 to reject obvious overlap.

+

Section 6

+

Motif chrome stays at the top while the document viewport scrolls.

+

Wheel input should move the document and scrollbar consistently.

+

Section 7

+

Black glyph pixels should stay inside their advertised font metrics.

+

No row should accumulate several old copies of the same text.

+

Section 8

+

Halfway marker for scroll replay and screenshot comparison.

+

The changed-region assertion should see movement in the document area.

+

Section 9

+

Expose handling should clear damaged children and parent backgrounds.

+

Same-window copies and clear areas are common scroll primitives.

+

Section 10

+

Additional rows keep the viewport busy after multiple wheel ticks.

+

Dense dark bands are treated as stale-text smoke-test failures.

+

Section 11

+

ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz.

+

MMMM WWWW mmmm wwww iii lll ttt fff.

+

Section 12

+

Repeated rows continue beyond the first viewport.

+

Scrolling back should repaint a plausible clean page.

+

Section 13

+

XDrawString, XDrawImageString, XClearArea, and Expose.

+

The local replay smoke test watches the rendered result.

+

Section 14

+

Another line with descenders: ggg jjj ppp qqq yyy.

+

Another line with capitals: WWW SOFTWARE PRODUCTS.

+

Section 15

+

Near the bottom of the deterministic fixture.

+

Scrollbars and text rows should remain stable.

+

Section 16

+

Final section for replay smoke testing.

+

End of document.

+ + diff --git a/tests/ui/replays/motif-fileview-done.replay b/tests/ui/replays/motif-fileview-done.replay new file mode 100644 index 0000000..f48106c --- /dev/null +++ b/tests/ui/replays/motif-fileview-done.replay @@ -0,0 +1,22 @@ +# High-value Motif smoke test for nested child button coordinates. +# +# scripts/run-ui-replay.py's `button N click` translates internally to +# press + delay + release for the LIBX11_COMPAT_REPLAY engine, so the +# Done button's activate callback fires deterministically. The runner +# does not implement a `click X Y` opcode, even though src/replay.c +# does; an earlier attempt to use that form failed in CI as "unknown +# replay command click". +delay 2500 +wait-window "fileview|File" 3000 +screenshot initial +assert-image initial common-visible.json +# (130, 540) lands on the Done button in fileview's language-selection +# dialog as it renders under Xvfb at the pinned 480x260+0+0 main shell +# geometry. The previous (110, 495) coordinate hit the OS list entry +# row instead, which only changed the selected radio button without +# dismissing the dialog, so fileview kept running and assert-exit +# tripped with "process is still running". +motion 130 540 +button 1 click +delay 1500 +assert-exit any diff --git a/tests/ui/replays/motif-wsm-labels.replay b/tests/ui/replays/motif-wsm-labels.replay new file mode 100644 index 0000000..4d9f590 --- /dev/null +++ b/tests/ui/replays/motif-wsm-labels.replay @@ -0,0 +1,6 @@ +# Workspace manager smoke test for visible room/menu labels. +delay 3000 +wait-window "WSM|wsm|Workspace" 4000 +screenshot initial +assert-image initial common-visible.json +assert-image initial motif-wsm-labels.json diff --git a/tests/ui/replays/violawww-help.replay b/tests/ui/replays/violawww-help.replay new file mode 100644 index 0000000..cf1e6ed --- /dev/null +++ b/tests/ui/replays/violawww-help.replay @@ -0,0 +1,21 @@ +# Deterministic local ViolaWWW Help menu smoke test. +# +# Uses XTest-style pointer motion and button events to click the Help +# menubar entry. This intentionally uses host screenshots rather than +# in-process snapshots: the Help menu is an override-redirect top-level +# popup, not part of vw's main window framebuffer. +delay 2500 +wait-window "vw|Viola" 3000 +screenshot initial +assert-image initial common-visible.json +motion 780 14 +button 1 press +delay 2000 +screenshot pressed +assert-image pressed common-visible.json +assert-image pressed violawww-help-menu.json +button 1 release +delay 1000 +screenshot released +assert-image released common-visible.json +assert-image released violawww-help-menu.json diff --git a/tests/ui/replays/violawww-scroll-system.replay b/tests/ui/replays/violawww-scroll-system.replay new file mode 100644 index 0000000..f56ae6f --- /dev/null +++ b/tests/ui/replays/violawww-scroll-system.replay @@ -0,0 +1,20 @@ +# System-X11 ViolaWWW scroll + resize differential capture. +# +# The system reference uses host X11 rendering and can trip +# libx11-compat-specific stale-glyph heuristics. Keep this replay focused +# on externally driven input and visible state changes. +delay 2500 +wait-window "vw|Viola" 3000 +screenshot initial +assert-image initial common-visible.json +motion 360 540 +wheel down 5 +delay 1000 +screenshot scrolled +assert-image scrolled common-visible.json +assert-image scrolled violawww-scroll-changed.json +resize 640 720 +delay 5000 +screenshot resized +assert-image resized common-visible.json +assert-image resized violawww-scroll-resized-changed.json diff --git a/tests/ui/replays/violawww-scroll.replay b/tests/ui/replays/violawww-scroll.replay new file mode 100644 index 0000000..a750edb --- /dev/null +++ b/tests/ui/replays/violawww-scroll.replay @@ -0,0 +1,37 @@ +# Deterministic local ViolaWWW wheel-scroll + resize smoke test. +# +# Exercises two event-handling paths in one pass: +# 1. Wheel scroll via XTest button 4/5 events. +# 2. Window resize via the replay engine's `resize` command, which +# pumps an SDL_USEREVENT to the main thread that calls +# SDL_SetWindowSize. The Cocoa backend then emits the standard +# window-resize event, which libx11-compat translates to +# ConfigureNotify for vw. +# 3. In-process snapshot of every captured frame, bypassing macOS +# screencapture's NSApp-deactivation that previously stalled vw's +# SDL event pump. +# +# Assertions: +# * Every frame is non-empty and passes the stale-text detector. +# * `scrolled` differs from `initial` in the document area. +# * `resized` differs from `initial` (vw reflowed after resize). +delay 2500 +wait-window "vw|Viola" 3000 +screenshot initial +assert-image initial common-visible.json +assert-image initial violawww-scroll.json +motion 360 540 +wheel down 5 +delay 1000 +screenshot scrolled +assert-image scrolled violawww-scroll.json +assert-image scrolled violawww-scroll-changed.json +# Resize the vw window. The new geometry has a meaningfully different +# aspect ratio so the document reflows visibly: line-wrap moves, the +# scrollbar resizes, and the Motif chrome rectangles adjust width. +resize 640 720 +delay 5000 +screenshot resized +assert-image resized common-visible.json +assert-image resized violawww-scroll-resized-changed.json +assert-image resized violawww-scroll-resized.json