diff --git a/.github/workflows/differential.yml b/.github/workflows/differential.yml index 5f50960..3e8903f 100644 --- a/.github/workflows/differential.yml +++ b/.github/workflows/differential.yml @@ -1,7 +1,7 @@ # Differential gates for the SDL2-based libx11-compat stack. # -# Each downstream target (Motif, ViolaWWW, Mosaic, Osiris, Xfig, GIMP) is -# built twice on the same GitHub-hosted runner: once against the +# Each downstream target (Motif, ViolaWWW, Mosaic, Osiris, Xfig, GIMP, +# XNEdit) is built twice on the same GitHub-hosted runner: once against the # system libX11 stack from apt and once against libx11-compat. The # resulting screenshots are compared under Xvfb. This is the local # counterpart to the SSH-driven path in scripts/run-*-differential-tests.py @@ -10,7 +10,7 @@ # Differential gates live in a separate workflow from ci.yml so the # fast PR feedback loop (lint / build / smoke) is not delayed by the # heavier double-build pipeline, and so each downstream target reports -# its own status check independent of the others. The six jobs run +# its own status check independent of the others. The seven jobs run # in parallel; total wallclock is bounded by the slowest single job # rather than the sum of all jobs. # @@ -629,3 +629,115 @@ jobs: - name: ccache stats if: ${{ !cancelled() }} run: ccache --show-stats + + # ---- XNEdit (Motif NEdit fork, Xft/fontconfig text) differential ---- + # + # XNEdit renders editor text through Xft/fontconfig rather than core X + # fonts, so this is the differential gate for the libXft-compat path. It + # is built twice on the runner: once against system libX11 + OpenMotif + # (libmotif-dev) + libXft, and once against libx11-compat + the bundled + # thentenaar/motif + the SDL_ttf-backed libXft-compat. Both render the + # editor under Xvfb and the startup + opened-fixture screens are compared. + # Validated on node11 (OpenMotif 2.3.8 + system Xft); the GitHub-runner + # path is the local counterpart and shares the script with the SSH route. + # The compat side builds the bundled Motif, so this job carries the + # MOTIF_BUILD_PKGS autoreconf chain and the MOTIF_YACC override. + xnedit-differential: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + MOTIF_YACC: "bison -y" + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install build + system X11 dependencies + # libmotif-dev supplies the system OpenMotif baseline; libxft-dev / + # libfontconfig1-dev / libxrender-dev back the system-side Xft text + # path. fonts-dejavu-core gives fontconfig a scalable family so the + # system side renders real glyphs rather than tofu under Xvfb. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + ${{ env.COMMON_BUILD_PKGS }} ${{ env.SYSTEM_X11_DEV_PKGS }} \ + ${{ env.DIFFERENTIAL_PKGS }} ${{ env.MOTIF_BUILD_PKGS }} \ + libmotif-dev libfontconfig1-dev libxft-dev libxrender-dev \ + fonts-dejavu-core + + - name: Cache upstream tarballs and extracted source/headers + # Exclude the XNEdit clone so its dedicated cache below owns it, + # matching how the other big source trees are split out. + uses: actions/cache@v5 + with: + path: | + build/upstream + !build/upstream/**/*.o + !build/upstream/**/*.d + !build/upstream/motif + !build/upstream/mosaic + !build/upstream/osiris + !build/upstream/xfig-* + !build/upstream/.cache/xfig-* + !build/upstream/gimp-* + !build/upstream/.cache/gimp-* + !build/upstream/xnedit + key: upstream-src-v2-${{ runner.os }}-${{ hashFiles('scripts/sync-upstream-headers.py') }} + restore-keys: | + upstream-src-v2-${{ runner.os }}- + + - name: Cache Motif source clone and autoreconf output + # The compat side reuses the in-tree thentenaar/motif as libXm / + # libMrm, so share the motif job's clone+autoreconf cache key. + uses: actions/cache@v5 + with: + path: build/upstream/motif + key: motif-src-${{ runner.os }}-${{ hashFiles('mk/motif.mk', 'compat/motif-patches/**') }} + + - name: Cache XNEdit source clone + uses: actions/cache@v5 + with: + path: build/upstream/xnedit + key: xnedit-src-${{ runner.os }}-${{ hashFiles('mk/xnedit.mk', 'compat/xnedit-patches/**') }} + + - name: Cache ccache + # XNEdit builds the bundled Motif on the compat side, so it shares + # the ccache-motifstack-diff-v3-* restore-keys prefix with motif / + # violawww / mosaic to prime the Motif gcc object cache; the save + # key stays distinct so this job persists its own snapshot. + uses: actions/cache@v5 + with: + path: ~/.cache/ccache + key: ccache-motifstack-diff-v3-${{ runner.os }}-${{ github.sha }}-xnedit + restore-keys: | + ccache-motifstack-diff-v3-${{ runner.os }}- + + - name: Configure ccache + run: | + ccache --max-size=2048M + ccache --zero-stats + + - name: Run XNEdit differential + env: + XNEDIT_DIFF_LOCAL: "1" + XNEDIT_DIFF_COMPARE_LOCATION: local + XNEDIT_DIFF_JOBS: "2" + run: make check-differential-xnedit + + - name: Upload XNEdit differential artifacts on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: xnedit-differential + path: | + build/xnedit-differential/report.tsv + build/xnedit-differential/junit.xml + build/xnedit-differential/logs + build/xnedit-differential/diff + build/xnedit-differential/system + build/xnedit-differential/compat + if-no-files-found: warn + retention-days: 7 + + - name: ccache stats + if: ${{ !cancelled() }} + run: ccache --show-stats diff --git a/Makefile b/Makefile index 2ebcd8e..536bdae 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ include mk/osiris.mk include mk/xclock.mk include mk/xfig.mk include mk/gimp-motif.mk +include mk/xnedit.mk include mk/tests.mk include mk/examples.mk include mk/upstream-headers.mk diff --git a/README.md b/README.md index a71eb5c..ba8e626 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,19 @@ but each exercises behavior that small examples do not reach. make check-differential-gimp-motif # toolbox diff vs system OpenMotif (needs remote host) ``` +- [XNEdit](https://github.com/unixwork/xnedit): the Motif NEdit fork that renders editor text through Xft/fontconfig builds against the bundled Motif, `libXt-compat`, and the SDL_ttf-backed `libXft-compat`. + It validates Unicode text rendering in an interactive Motif editor, including Latin-1, Greek, and CJK glyph fallback through the Xft path. + Replay smoke checks cover startup and opening a controlled UTF-8 fixture; screenshot-based differential checks compare the same states against native libX11/OpenMotif/Xft on `node11`. + + XNEdit running through libx11-compat on macOS + + ```sh + make xnedit # build XNEdit (depends on motif + Xft shim) + build/xnedit/source/source/xnedit tests/ui/fixtures/xnedit-fixture.txt + make check-smoke-xnedit # replay-driven startup + Unicode fixture smoke + make check-differential-xnedit # startup + fixture diff vs native X11/Xft (needs remote host) + ``` + The `check-smoke-*` targets use deterministic replay files and in-process snapshots, with artifacts written under `build/ui-smoke/`. `make profile-ui` runs the Motif, ViolaWWW, and Mosaic replay smokes with timing capture and prints the generated `metrics.tsv` and `render-stats.tsv` paths; the Osiris and Xfig smokes are still invoked individually via `make check-smoke-osiris` / `make check-smoke-xfig`. They do not require `node11`, `xdotool`, or a native X11 reference run. diff --git a/assets/xnedit.png b/assets/xnedit.png new file mode 100644 index 0000000..69bbebc Binary files /dev/null and b/assets/xnedit.png differ diff --git a/compat/libxt-patches/keyboard-focus-xinput.patch b/compat/libxt-patches/keyboard-focus-xinput.patch new file mode 100644 index 0000000..a8c3737 --- /dev/null +++ b/compat/libxt-patches/keyboard-focus-xinput.patch @@ -0,0 +1,14 @@ +diff --git a/Keyboard.c b/Keyboard.c +index 6dcb8d4..73f10f5 100644 +--- a/Keyboard.c ++++ b/Keyboard.c +@@ -782,6 +782,9 @@ XtSetKeyboardFocus(Widget widget, Widget descendant) + False, QueryEventMask, (XtPointer) widget); + pwi->map_handler_added = TRUE; + pwi->queryEventDescendant = descendant; ++ } else if (target) { ++ XSetInputFocus(XtDisplay(widget), XtWindow(target), ++ RevertToParent, CurrentTime); + } + } + } diff --git a/compat/xnedit-patches/stdlib-textfield.patch b/compat/xnedit-patches/stdlib-textfield.patch new file mode 100644 index 0000000..e70cdb7 --- /dev/null +++ b/compat/xnedit-patches/stdlib-textfield.patch @@ -0,0 +1,60 @@ +diff --git a/util/textfield.c b/util/textfield.c +index 7ab1422..e42b0e2 100644 +--- a/util/textfield.c ++++ b/util/textfield.c +@@ -23,6 +23,7 @@ + + #include + #include ++#include + #include + + #include "../source/textBuf.h" /* Utf8CharLen */ +diff --git a/util/colorchooser.c b/util/colorchooser.c +index 73c0926..cd4f2e0 100644 +--- a/util/colorchooser.c ++++ b/util/colorchooser.c +@@ -27,6 +27,7 @@ + + #include + #include ++#include + + #define XNE_COLOR_DIALOG_TITLE "Select Color" + +diff --git a/source/file.h b/source/file.h +index aab06a0..ef00b86 100644 +--- a/source/file.h ++++ b/source/file.h +@@ -30,6 +30,7 @@ + #include "../util/getfiles.h" + + #include ++#include + + /* flags for EditExistingFile */ + #define CREATE 1 +diff --git a/source/shift.c b/source/shift.c +index 17dbdcf..7f381a6 100644 +--- a/source/shift.c ++++ b/source/shift.c +@@ -42,6 +42,7 @@ + + #include + #include ++#include + #include + #include + #include +diff --git a/source/highlightData.c b/source/highlightData.c +index 4d14022..02d1a31 100644 +--- a/source/highlightData.c ++++ b/source/highlightData.c +@@ -48,6 +48,7 @@ + + #include + #include ++#include + #include + #ifndef __MVS__ + #include diff --git a/include/X11/Xft/Xft.h b/include/X11/Xft/Xft.h index 12c627a..1163102 100644 --- a/include/X11/Xft/Xft.h +++ b/include/X11/Xft/Xft.h @@ -111,6 +111,11 @@ XftDraw *XftDrawCreate(Display *dpy, Colormap colormap); void XftDrawDestroy(XftDraw *draw); void XftDrawChange(XftDraw *draw, Drawable drawable); +Bool XftDrawSetClipRectangles(XftDraw *draw, + int x_origin, + int y_origin, + const XRectangle *rects, + int n); Display *XftDrawDisplay(XftDraw *draw); Drawable XftDrawDrawable(XftDraw *draw); Colormap XftDrawColormap(XftDraw *draw); diff --git a/include/fontconfig/fontconfig.h b/include/fontconfig/fontconfig.h index e2fa834..250dd70 100644 --- a/include/fontconfig/fontconfig.h +++ b/include/fontconfig/fontconfig.h @@ -11,7 +11,11 @@ typedef int FcResult; typedef int FcType; typedef struct _FcPattern FcPattern; typedef struct _FcObjectSet FcObjectSet; -typedef struct _FcFontSet FcFontSet; +typedef struct _FcFontSet { + int nfont; + int sfont; + FcPattern **fonts; +} FcFontSet; typedef struct _FcCharSet FcCharSet; typedef struct _FcMatrix { double xx; @@ -45,6 +49,8 @@ typedef struct _FcValue { #define FcResultNoId 3 #define FcResultOutOfMemory 4 +#define FcMatchPattern 0 + #define FcTypeVoid 0 #define FcTypeInteger 1 #define FcTypeDouble 2 @@ -121,9 +127,24 @@ void FcPatternReference(FcPattern *pattern); FcBool FcPatternDel(FcPattern *pattern, const char *object); FcBool FcPatternRemove(FcPattern *pattern, const char *object, int n); int FcUcs4ToUtf8(FcChar32 ucs4, FcChar8 dest[FC_UTF8_MAX_LEN]); +int FcUtf8ToUcs4(const FcChar8 *src, FcChar32 *dst, int len); FcPattern *FcNameParse(const FcChar8 *name); FcBool FcConfigSubstitute(FcConfig *config, FcPattern *pattern, int kind); void FcDefaultSubstitute(FcPattern *pattern); +FcPattern *FcFontMatch(FcConfig *config, FcPattern *pattern, FcResult *result); +FcFontSet *FcFontList(FcConfig *config, FcPattern *pattern, FcObjectSet *os); +void FcFontSetDestroy(FcFontSet *set); +FcCharSet *FcCharSetCreate(void); +FcBool FcCharSetAddChar(FcCharSet *charset, FcChar32 ucs4); +FcBool FcCharSetHasChar(const FcCharSet *charset, FcChar32 ucs4); +void FcCharSetDestroy(FcCharSet *charset); +FcObjectSet *FcObjectSetCreate(void); +FcBool FcObjectSetAdd(FcObjectSet *os, const char *object); +void FcObjectSetDestroy(FcObjectSet *os); +FcBool FcPatternAdd(FcPattern *pattern, + const char *object, + FcValue value, + FcBool append); FcBool FcPatternAddString(FcPattern *pattern, const char *object, const FcChar8 *s); diff --git a/mk/config.mk b/mk/config.mk index dd479d0..2206a7d 100644 --- a/mk/config.mk +++ b/mk/config.mk @@ -26,7 +26,13 @@ CPPFLAGS += -Iinclude -Isrc \ -iquote $(OUT)/upstream/src \ $(SDL_CPPFLAGS) $(PIXMAN_CFLAGS) \ -DNARROWPROTO -DXTHREADS -D_GNU_SOURCE -CFLAGS += -std=c99 -Wall -Wextra -Wno-unused-parameter -fPIC +# SDL3's SDL_system.h forward-declares `typedef union _XEvent XEvent;` (for the +# X11 event-hook API) with no opt-out, and Xlib.h defines the same typedef. The +# two are identical, so it is harmless; -std=c99 just flags the C11-legal +# repeat. Silence only that case (conflicting typedefs stay hard errors); gcc +# does not warn here and accepts the unknown -Wno- option quietly. +CFLAGS += -std=c99 -Wall -Wextra -Wno-unused-parameter \ + -Wno-typedef-redefinition -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 diff --git a/mk/libxt.mk b/mk/libxt.mk index eba8610..029ee8f 100644 --- a/mk/libxt.mk +++ b/mk/libxt.mk @@ -18,6 +18,15 @@ LIBXT_GEN_DIR := $(OUT)/libxt-gen LIBXT_OBJ_DIR := $(OUT)/libxt LIBXT_HOST_DIR := $(OUT)/host LIBXT_TARGET := $(OUT)/libXt-compat.so +LIBXT_PATCHES := $(sort $(wildcard compat/libxt-patches/*.patch)) +LIBXT_PATCH_LIST_FILE := $(OUT)/upstream/.libxt-patch-list +$(shell mkdir -p $(dir $(LIBXT_PATCH_LIST_FILE)); \ + new='$(sort $(notdir $(wildcard compat/libxt-patches/*.patch)))'; \ + old=$$(cat $(LIBXT_PATCH_LIST_FILE) 2>/dev/null || true); \ + if [ "$$new" != "$$old" ]; then \ + printf '%s\n' "$$new" > $(LIBXT_PATCH_LIST_FILE); \ + fi) +LIBXT_PATCH_STAMP := $(LIBXT_SRC_DIR)/.compat-patches-stamp # mk/libxt.mk is included before mk/upstream-headers.mk, but its rules need # this stamp while make parses prerequisites. Keep the value aligned with the @@ -98,15 +107,32 @@ $(LIBXT_HOST_DIR) $(LIBXT_GEN_DIR) $(LIBXT_OBJ_DIR): # Compile makestrs as a host-side tool. It is a single-file standalone # generator that depends only on libc; no need for any libx11-compat -# include paths. -$(LIBXT_HOST_MAKESTRS): $(UPSTREAM_HEADERS_STAMP) | $(LIBXT_HOST_DIR) +# include paths. Depend on the staged makestrs.c (not just the headers +# stamp) so the LIBXT_PATCH_STAMP recipe, which runs `fetch --force` and +# briefly removes and re-stages util/makestrs.c, cannot race this compile +# under parallel make and leave it reading a half-removed file. +$(LIBXT_HOST_MAKESTRS): $(LIBXT_UTIL_DIR)/makestrs.c | $(LIBXT_HOST_DIR) @echo " HOSTCC $(LIBXT_UTIL_DIR)/makestrs.c" $(Q)$(HOST_CC) -O2 -o $@ $(LIBXT_UTIL_DIR)/makestrs.c # The upstream sync stages libXt sources and util files as side effects. # Declare them so a clean build knows how to fetch these normal # prerequisites before trying to build libXt objects or the host generator. -$(LIBXT_SRCS) $(LIBXT_UTIL_DIR)/makestrs.c $(LIBXT_STRING_LIST) $(LIBXT_TEMPLATES): $(UPSTREAM_HEADERS_STAMP) +$(LIBXT_PATCH_STAMP): $(UPSTREAM_HEADERS_STAMP) $(LIBXT_PATCHES) $(LIBXT_PATCH_LIST_FILE) mk/libxt.mk + @echo " PATCH libXt" + $(Q)$(PYTHON) $(UPSTREAM_SYNC) fetch --force $(UPSTREAM_HEADERS_DIR) + $(Q)set -e; for patch in $(abspath $(LIBXT_PATCHES)); do \ + if patch -d $(abspath $(LIBXT_SRC_DIR)) -p1 --dry-run < "$$patch" >/dev/null; then \ + patch -d $(abspath $(LIBXT_SRC_DIR)) -p1 < "$$patch" >/dev/null; \ + elif patch -d $(abspath $(LIBXT_SRC_DIR)) -p1 -R --dry-run < "$$patch" >/dev/null; then \ + :; \ + else \ + echo " PATCH failed $$patch" >&2; exit 1; \ + fi; \ + done + $(Q)touch $@ + +$(LIBXT_SRCS) $(LIBXT_UTIL_DIR)/makestrs.c $(LIBXT_STRING_LIST) $(LIBXT_TEMPLATES): $(UPSTREAM_HEADERS_STAMP) $(LIBXT_PATCH_STAMP) # Run makestrs from the topdir so the "util/StrDefs.ct" / "util/StrDefs.ht" # paths embedded in string.list resolve against cwd. The generated headers @@ -142,7 +168,7 @@ $(OUT)/upstream/include/X11/Shell.h: $(LIBXT_GEN_DIR)/Shell.h # files have been staged before the recipe reads them; the explicit # dependency on LIBXT_GEN_HEADERS ensures StringDefs.h is available before # any unit that quotes it compiles. -$(LIBXT_OBJ_DIR)/%.o: $(UPSTREAM_HEADERS_STAMP) $(LIBXT_GEN_HEADERS) \ +$(LIBXT_OBJ_DIR)/%.o: $(UPSTREAM_HEADERS_STAMP) $(LIBXT_PATCH_STAMP) $(LIBXT_GEN_HEADERS) \ $(LIBXT_STAGED_H) $(SDL_BACKEND_STAMP) | $(LIBXT_OBJ_DIR) @echo " CC $(LIBXT_SRC_DIR)/$*.c" $(Q)$(CC) $(LIBXT_CPPFLAGS) $(CFLAGS) $(LIBXT_CFLAGS) $(CFLAGS_EXTRA) \ diff --git a/mk/xnedit.mk b/mk/xnedit.mk new file mode 100644 index 0000000..54f13b8 --- /dev/null +++ b/mk/xnedit.mk @@ -0,0 +1,245 @@ +XNEDIT_URL := https://github.com/unixwork/xnedit +XNEDIT_REVISION := 7f775fce45a24cb2fa910063bd8c0efb727e1668 +XNEDIT_CLONE_FLAGS ?= --filter=blob:none --no-checkout +XNEDIT_SRC_DIR := $(OUT)/upstream/xnedit +XNEDIT_SOURCE_STAMP := $(XNEDIT_SRC_DIR)/.source-stamp +XNEDIT_PATCHES := $(sort $(wildcard compat/xnedit-patches/*.patch)) + +XNEDIT_BUILD_DIR := $(OUT)/xnedit +XNEDIT_WORK_DIR := $(XNEDIT_BUILD_DIR)/source +XNEDIT_BIN := $(XNEDIT_WORK_DIR)/source/xnedit +XNEDIT_LOG := $(abspath $(XNEDIT_BUILD_DIR))/build.log +XNEDIT_LIB_ALIASES := $(XNEDIT_BUILD_DIR)/lib-aliases +XNEDIT_LIB_ALIASES_STAMP := $(XNEDIT_LIB_ALIASES)/.stamp +XNEDIT_SYSROOT := $(XNEDIT_BUILD_DIR)/sysroot +XNEDIT_SYSROOT_STAMP := $(XNEDIT_SYSROOT)/.stamp + +XNEDIT_RPATH_FLAGS := +ifeq ($(UNAME_S),Linux) + XNEDIT_RPATH_FLAGS += -Wl,-rpath,$(abspath $(OUT)) \ + -Wl,-rpath,$(abspath $(XNEDIT_LIB_ALIASES)) \ + -Wl,-rpath-link,$(abspath $(OUT)) +endif +ifeq ($(UNAME_S),Darwin) + XNEDIT_RPATH_FLAGS += -Wl,-rpath,$(abspath $(OUT)) \ + -Wl,-rpath,$(abspath $(XNEDIT_LIB_ALIASES)) -liconv \ + -framework CoreFoundation +endif + +XNEDIT_C_OPT_FLAGS := $(CFLAGS) $(CFLAGS_EXTRA) \ + -include stdlib.h -include sys/stat.h -I$(abspath $(XNEDIT_SYSROOT)) +XNEDIT_LD_OPT_FLAGS := -L$(abspath $(XNEDIT_LIB_ALIASES)) \ + -L$(abspath $(OUT)) $(XNEDIT_RPATH_FLAGS) + +$(XNEDIT_SOURCE_STAMP): mk/xnedit.mk $(XNEDIT_PATCHES) + @echo " GIT $(XNEDIT_URL)" + $(Q)mkdir -p $(dir $(XNEDIT_SRC_DIR)) + $(Q)test -d $(XNEDIT_SRC_DIR)/.git || \ + git clone $(MOTIF_GIT_Q) $(XNEDIT_CLONE_FLAGS) $(XNEDIT_URL) $(XNEDIT_SRC_DIR) + $(Q)cd $(XNEDIT_SRC_DIR) && git fetch $(MOTIF_GIT_Q) origin $(XNEDIT_REVISION) || \ + (cd $(XNEDIT_SRC_DIR) && git fetch $(MOTIF_GIT_Q) origin) + $(Q)cd $(XNEDIT_SRC_DIR) && \ + git checkout $(MOTIF_GIT_Q) --detach $(XNEDIT_REVISION) && \ + git reset --hard $(MOTIF_GIT_Q) $(XNEDIT_REVISION) >/dev/null && \ + git clean $(MOTIF_GIT_Q) -fdx >/dev/null + $(Q)set -e; for patch in $(abspath $(XNEDIT_PATCHES)); do \ + cd $(abspath $(XNEDIT_SRC_DIR)); \ + if git apply --check "$$patch"; then \ + git apply "$$patch"; \ + elif git apply --reverse --check "$$patch"; then \ + :; \ + else \ + echo " PATCH failed $$patch" >&2; exit 1; \ + fi; \ + done + $(Q)touch $@ + +$(XNEDIT_SYSROOT_STAMP): $(LIBXT_TARGET) $(MOTIF_STAGE_STAMP) mk/xnedit.mk + @echo " SYSROOT xnedit" + $(Q)rm -rf $(XNEDIT_SYSROOT) + $(Q)mkdir -p $(XNEDIT_SYSROOT)/X11/extensions $(XNEDIT_SYSROOT)/Xm + $(Q)for e in $(abspath $(OUT)/upstream/include)/X11/*; do \ + b=$$(basename "$$e"); \ + [ "$$b" = extensions ] && continue; \ + ln -sf "$$e" "$(XNEDIT_SYSROOT)/X11/$$b"; \ + done + $(Q)for e in $(abspath $(OUT)/upstream/include)/X11/extensions/* \ + $(abspath include)/X11/extensions/*; do \ + ln -sf "$$e" "$(XNEDIT_SYSROOT)/X11/extensions/$$(basename "$$e")"; \ + done + $(Q)for e in $(abspath include)/X11/*; do \ + b=$$(basename "$$e"); \ + [ -e "$(XNEDIT_SYSROOT)/X11/$$b" ] || \ + ln -sf "$$e" "$(XNEDIT_SYSROOT)/X11/$$b"; \ + done + $(Q)for h in $(abspath $(MOTIF_SRC_DIR))/lib/Xm/*.h \ + $(abspath $(MOTIF_BUILD_DIR))/lib/Xm/*.h; do \ + ln -sf "$$h" "$(XNEDIT_SYSROOT)/Xm/$$(basename "$$h")"; \ + done + $(Q)touch $@ + +$(XNEDIT_LIB_ALIASES_STAMP): $(TARGET) $(LIBXT_TARGET) $(XEXT_COMPAT_TARGET) \ + $(XFT_COMPAT_TARGET) $(MOTIF_STAGE_STAMP) mk/xnedit.mk + @mkdir -p $(XNEDIT_LIB_ALIASES) + $(Q)rm -f $(XNEDIT_LIB_ALIASES)/libX*.so $(XNEDIT_LIB_ALIASES)/libX*.dylib \ + $(XNEDIT_LIB_ALIASES)/libXm.so $(XNEDIT_LIB_ALIASES)/libXm.dylib + $(Q)set -e; for pair in \ + libX11.so:libX11-compat.so \ + libXt.so:libXt-compat.so \ + libXext.so:libXext-compat.so \ + libXft.so:libXft-compat.so \ + libXrender.so:libX11-compat.so \ + libXm.so:libXm.so; do \ + alias="$${pair%%:*}"; target="$${pair##*:}"; \ + ln -sf "$(abspath $(OUT))/$$target" "$(XNEDIT_LIB_ALIASES)/$$alias"; \ + done +ifeq ($(UNAME_S),Darwin) + $(Q)set -e; for pair in \ + libX11.dylib:libX11-compat.so \ + libXt.dylib:libXt-compat.so \ + libXext.dylib:libXext-compat.so \ + libXft.dylib:libXft-compat.so \ + libXrender.dylib:libX11-compat.so \ + libXm.dylib:libXm.so; do \ + alias="$${pair%%:*}"; target="$${pair##*:}"; \ + ln -sf "$(abspath $(OUT))/$$target" "$(XNEDIT_LIB_ALIASES)/$$alias"; \ + done +endif + $(Q)touch $@ + +.PHONY: xnedit xnedit-clean check-smoke-xnedit check-differential-xnedit +## Build unixwork/xnedit against the Motif + Xft compatibility stack +xnedit: $(XNEDIT_BIN) + +$(XNEDIT_BIN): $(XNEDIT_SOURCE_STAMP) $(PKGCONFIG_FILES) \ + $(XNEDIT_SYSROOT_STAMP) $(XNEDIT_LIB_ALIASES_STAMP) + @mkdir -p $(XNEDIT_BUILD_DIR) + $(Q)rm -rf $(XNEDIT_WORK_DIR) + $(Q)mkdir -p $(XNEDIT_WORK_DIR) + $(Q)tar -cf - -C $(XNEDIT_SRC_DIR) . | tar -xf - -C $(XNEDIT_WORK_DIR) + @echo " MAKE xnedit" + $(Q)env -u MAKEFLAGS -u MFLAGS \ + PKG_CONFIG_PATH=$(abspath $(PKGCONFIG_DIR)) \ + DYLD_LIBRARY_PATH=$(abspath $(OUT)):$(abspath $(XNEDIT_LIB_ALIASES))$${DYLD_LIBRARY_PATH:+:$$DYLD_LIBRARY_PATH} \ + LD_LIBRARY_PATH=$(abspath $(OUT)):$(abspath $(XNEDIT_LIB_ALIASES))$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \ + $(MAKE) -C $(XNEDIT_WORK_DIR) linux \ + CC='$(CC)' \ + C_OPT_FLAGS='$(XNEDIT_C_OPT_FLAGS)' \ + LD_OPT_FLAGS='$(XNEDIT_LD_OPT_FLAGS)' \ + >> $(XNEDIT_LOG) 2>&1 || { \ + echo " FAIL see $(XNEDIT_LOG)" >&2; \ + tail -60 $(XNEDIT_LOG) >&2; \ + exit 1; \ + } + +xnedit-clean: + @echo " CLEAN xnedit" + $(Q)rm -rf $(XNEDIT_BUILD_DIR) + +XNEDIT_UI_LIB_PATH = $(abspath $(OUT)):$(abspath $(XNEDIT_LIB_ALIASES))$(if $(SDL_RUNTIME_LIBDIR),:$(SDL_RUNTIME_LIBDIR)) +XNEDIT_SMOKE_GEOMETRY ?= 120x45+0+0 +XNEDIT_SMOKE_REGION ?= 0,0,900,650 +XNEDIT_FIXTURE := $(abspath tests/ui/fixtures/xnedit-fixture.txt) + +xnedit_ui_env = \ + --env DYLD_LIBRARY_PATH=$(XNEDIT_UI_LIB_PATH)$${DYLD_LIBRARY_PATH:+:$$DYLD_LIBRARY_PATH} \ + --env LD_LIBRARY_PATH=$(XNEDIT_UI_LIB_PATH)$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} \ + --env LIBX11_COMPAT_FONT_DIR=$(abspath $(OUT))/../fonts \ + --env LIBX11_COMPAT_SCREEN_GEOMETRY=1280x1024 \ + --env XNEDIT_HOME=$(abspath $(@D))/home + +## Run replay-based XNEdit smoke checks against libx11-compat +check-smoke-xnedit: $(UI_SMOKE_OUT_ROOT)/xnedit-startup/.stamp \ + $(UI_SMOKE_OUT_ROOT)/xnedit-fixture/.stamp \ + $(UI_SMOKE_OUT_ROOT)/xnedit-typing/.stamp + +$(UI_SMOKE_OUT_ROOT)/xnedit-startup/.stamp: FORCE $(XNEDIT_BIN) + $(Q)rm -rf $(abspath $(UI_SMOKE_OUT_ROOT))/xnedit-startup + $(Q)$(PYTHON) scripts/run-ui-replay.py \ + --name xnedit-startup \ + --app $(abspath $(XNEDIT_BIN)) \ + --app-arg=-svrname --app-arg=xnedit-startup \ + --app-arg=-geometry --app-arg=$(XNEDIT_SMOKE_GEOMETRY) \ + --workdir $(abspath $(XNEDIT_WORK_DIR))/source \ + --replay tests/ui/replays/xnedit-startup.replay \ + --out-root $(abspath $(UI_SMOKE_OUT_ROOT))/xnedit-startup \ + --display $(UI_REPLAY_DISPLAY) \ + --geometry $(UI_REPLAY_GEOMETRY) \ + --screenshot-command $(UI_REPLAY_SCREENSHOT_COMMAND) \ + --screenshot-region $(XNEDIT_SMOKE_REGION) \ + --in-process-snapshots \ + $(UI_REPLAY_XVFB) \ + $(xnedit_ui_env) + $(Q)touch $@ + +$(UI_SMOKE_OUT_ROOT)/xnedit-fixture/.stamp: FORCE $(XNEDIT_BIN) $(XNEDIT_FIXTURE) + $(Q)rm -rf $(abspath $(UI_SMOKE_OUT_ROOT))/xnedit-fixture + $(Q)$(PYTHON) scripts/run-ui-replay.py \ + --name xnedit-fixture \ + --app $(abspath $(XNEDIT_BIN)) \ + --app-arg=-svrname --app-arg=xnedit-fixture \ + --app-arg=-geometry --app-arg=$(XNEDIT_SMOKE_GEOMETRY) \ + --app-arg=$(XNEDIT_FIXTURE) \ + --workdir $(abspath $(XNEDIT_WORK_DIR))/source \ + --replay tests/ui/replays/xnedit-fixture.replay \ + --out-root $(abspath $(UI_SMOKE_OUT_ROOT))/xnedit-fixture \ + --display $(UI_REPLAY_DISPLAY) \ + --geometry $(UI_REPLAY_GEOMETRY) \ + --screenshot-command $(UI_REPLAY_SCREENSHOT_COMMAND) \ + --screenshot-region $(XNEDIT_SMOKE_REGION) \ + --in-process-snapshots \ + $(UI_REPLAY_XVFB) \ + $(xnedit_ui_env) + $(Q)touch $@ + +$(UI_SMOKE_OUT_ROOT)/xnedit-typing/.stamp: FORCE $(XNEDIT_BIN) + $(Q)rm -rf $(abspath $(UI_SMOKE_OUT_ROOT))/xnedit-typing + $(Q)$(PYTHON) scripts/run-ui-replay.py \ + --name xnedit-typing \ + --app $(abspath $(XNEDIT_BIN)) \ + --app-arg=-svrname --app-arg=xnedit-typing \ + --app-arg=-geometry --app-arg=$(XNEDIT_SMOKE_GEOMETRY) \ + --workdir $(abspath $(XNEDIT_WORK_DIR))/source \ + --replay tests/ui/replays/xnedit-typing.replay \ + --out-root $(abspath $(UI_SMOKE_OUT_ROOT))/xnedit-typing \ + --display $(UI_REPLAY_DISPLAY) \ + --geometry $(UI_REPLAY_GEOMETRY) \ + --screenshot-command $(UI_REPLAY_SCREENSHOT_COMMAND) \ + --screenshot-region $(XNEDIT_SMOKE_REGION) \ + --in-process-snapshots \ + $(UI_REPLAY_XVFB) \ + $(xnedit_ui_env) + $(Q)touch $@ + +XNEDIT_DIFF_REMOTE ?= node11 +XNEDIT_DIFF_REMOTE_ROOT ?= /tmp/libx11-compat-xnedit-differential +XNEDIT_DIFF_DISPLAY ?= 127 +XNEDIT_DIFF_JOBS ?= 1 +XNEDIT_DIFF_INSTALL_DEPS ?= 0 +XNEDIT_DIFF_LOCAL ?= 0 +XNEDIT_DIFF_MAE_THRESHOLD ?= 0.18 +XNEDIT_DIFF_CHANGED_THRESHOLD ?= 0.46 +XNEDIT_DIFF_GEOMETRY ?= 1280x1024x24 +XNEDIT_DIFF_TOP ?= 12 +XNEDIT_DIFF_COMPARE_LOCATION ?= $(if $(filter 1 yes true,$(XNEDIT_DIFF_LOCAL)),local,remote) +XNEDIT_DIFF_OUT_ROOT ?= $(OUT)/xnedit-differential +XNEDIT_DIFF_SCREENSHOT_REGION ?= $(XNEDIT_SMOKE_REGION) + +xnedit_diff_env = \ + XNEDIT_DIFF_REMOTE='$(XNEDIT_DIFF_REMOTE)' \ + XNEDIT_DIFF_REMOTE_ROOT='$(XNEDIT_DIFF_REMOTE_ROOT)' \ + XNEDIT_DIFF_DISPLAY='$(XNEDIT_DIFF_DISPLAY)' \ + XNEDIT_DIFF_JOBS='$(XNEDIT_DIFF_JOBS)' \ + XNEDIT_DIFF_MAE_THRESHOLD='$(XNEDIT_DIFF_MAE_THRESHOLD)' \ + XNEDIT_DIFF_CHANGED_THRESHOLD='$(XNEDIT_DIFF_CHANGED_THRESHOLD)' \ + XNEDIT_DIFF_GEOMETRY='$(XNEDIT_DIFF_GEOMETRY)' \ + XNEDIT_DIFF_TOP='$(XNEDIT_DIFF_TOP)' \ + XNEDIT_DIFF_COMPARE_LOCATION='$(XNEDIT_DIFF_COMPARE_LOCATION)' \ + XNEDIT_DIFF_OUT_ROOT='$(abspath $(XNEDIT_DIFF_OUT_ROOT))' \ + XNEDIT_DIFF_SCREENSHOT_REGION='$(XNEDIT_DIFF_SCREENSHOT_REGION)' + +## Compare XNEdit screenshots for system libX11 vs libx11-compat. +check-differential-xnedit: + $(Q)$(xnedit_diff_env) $(PYTHON) scripts/run-xnedit-differential-tests.py \ + $(if $(filter 1 yes true,$(XNEDIT_DIFF_INSTALL_DEPS)),--install-deps) \ + $(if $(filter 1 yes true,$(XNEDIT_DIFF_LOCAL)),--local) diff --git a/scripts/run-ui-replay.py b/scripts/run-ui-replay.py index 1dbd457..27f3068 100755 --- a/scripts/run-ui-replay.py +++ b/scripts/run-ui-replay.py @@ -115,6 +115,21 @@ class ReplayError(Exception): pass +def replay_keysym(code): + """Map a replay key scancode to an xdotool keysym name. + + Replays carry SDL keycodes (printable ASCII for letters, 225 for the + low byte of SDLK_LSHIFT). xdotool wants keysym names, so translate the + common cases the differential needs and reject anything unmapped rather + than silently passing a bogus argument to xdotool. + """ + if code == 225: + return "shift" + if 0x20 <= code <= 0x7E: + return chr(code) + raise ReplayError(f"no xdotool keysym for replay code {code}") + + @dataclass class ArtifactPaths: out_root: Path @@ -455,11 +470,18 @@ def write_internal_replay(source_path, dest_path, snapshot_dir=None, sync_dir=No 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") + if len(parts) == 2: + lines.append(f"key {int(parts[1])} press") + lines.append("delay 10") + lines.append(f"key {int(parts[1])} release") + elif len(parts) == 3 and parts[2] in ("down", "up"): + action = "press" if parts[2] == "down" else "release" + lines.append(f"key {int(parts[1])} {action}") + lines.append("delay 10") + else: + raise ReplayError( + f"{source_path}:{lineno}: key expects scancode [down|up]" + ) elif command == "screenshot": if snapshot_dir is None: # Host screenshots are invisible to the in-process replay @@ -1342,10 +1364,18 @@ def run_replay(args): xdotool(env, "click", button) time.sleep(0.05) elif command == "key": - if len(parts) != 2: - raise ReplayError("key expects keysym") + if len(parts) not in (2, 3): + raise ReplayError("key expects scancode [down|up]") + if len(parts) == 3 and parts[2] not in ("down", "up"): + raise ReplayError("key direction must be down|up") if args.input_backend == "xdotool": - xdotool(env, "key", parts[1]) + keysym = replay_keysym(int(parts[1])) + if len(parts) == 2: + xdotool(env, "key", keysym) + elif parts[2] == "down": + xdotool(env, "keydown", keysym) + else: + xdotool(env, "keyup", keysym) elif command == "focus-at": if len(parts) != 3: raise ReplayError("focus-at expects target-local x y") diff --git a/scripts/run-xnedit-differential-tests.py b/scripts/run-xnedit-differential-tests.py new file mode 100755 index 0000000..a100122 --- /dev/null +++ b/scripts/run-xnedit-differential-tests.py @@ -0,0 +1,738 @@ +#!/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" / "xnedit-differential" + + +def run(cmd, *, cwd=ROOT, input_text=None): + print("+", " ".join(str(c) for c in cmd), flush=True) + subprocess.run(cmd, cwd=cwd, 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 execute(args, script): + """Run a build/capture/compare shell payload locally or via SSH.""" + if args.local: + run(["sh", "-s"], input_text=script) + else: + ssh(args.remote, script) + + +def remote_uri(args, path): + """Format a path for rsync; local mode strips the remote: prefix.""" + return str(path) if args.local else f"{args.remote}:{path}" + + +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 parse_env_bool(name, default=False): + value = os.environ.get(name) + if value is None or value == "": + return default + return value.lower() in ("1", "yes", "true", "on") + + +def check_local_paths(out_root, remote_root): + """Reject --remote-root values that fetch_results would delete. + + fetch_results() rmtrees out_root/{system,compat,logs,diff} before + rsyncing from remote_root/{screens/system,screens/compat,logs,diff}. + If remote_root equals out_root or lives inside one of those four + subdirectories, the rmtree wipes the staging tree before rsync can + read from it. + """ + out_root = Path(out_root).resolve() + remote_root = Path(remote_root).resolve() + + if remote_root == out_root: + raise ValueError( + "--remote-root cannot equal --out-root in local mode; " + "fetch_results would delete out_root/logs and out_root/diff " + "before rsync." + ) + + for name in ("system", "compat", "logs", "diff"): + dest = out_root / name + try: + remote_root.relative_to(dest) + except ValueError: + continue + raise ValueError( + f"--remote-root {remote_root} lives inside fetch destination " + f"{dest}; fetch_results would delete the staging tree before " + f"rsync. Pick a remote_root outside out_root/{{system,compat," + f"logs,diff}}." + ) + + +def sync_repo(args): + if args.local: + Path(args.remote_root).mkdir(parents=True, exist_ok=True) + return str(ROOT) + 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(args.remote_root + '/system-xnedit')} " + 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 + # Do not let an unrelated third-party repo with a transient GPG or + # signature error abort the whole differential: the metadata refresh + # is best-effort, and the install below still fails loudly if a + # package we actually need cannot be resolved from the cached lists. + sudo apt-get update || true + sudo apt-get install -y --no-install-recommends \\ + autoconf automake build-essential ca-certificates git imagemagick \\ + libfontconfig1-dev libmotif-dev libx11-dev libxext-dev libxft-dev \\ + libxrender-dev libxt-dev make patch pkg-config python3 python3-pil \\ + rsync xauth xvfb xdotool +fi +""" + + # Offset compat-side Xvfb by 1 so the parallel screenshot block + # below can run system-side and compat-side captures concurrently. + compat_display_num = int(args.display) + 1 + + return f""" +set -eu + +{install_deps} + +need() {{ + command -v "$1" >/dev/null 2>&1 || {{ + echo "missing required command: $1" >&2 + exit 127 + }} +}} + +need gcc +need import +need make +need patch +need pkg-config +need python3 +need rsync +need Xvfb +need xdotool + +remote_root={q(args.remote_root)} +repo={q(remote_repo)} +system_build="$remote_root/system-xnedit" +system_logs="$remote_root/logs/system" +compat_logs="$remote_root/logs/compat" +system_screens="$remote_root/screens/system" +compat_screens="$remote_root/screens/compat" +display=:{q(args.display)} +compat_display=:{compat_display_num} + +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_xnedit() {{ + name=$1 + app=$2 + workdir=$3 + replay=$4 + libpath=$5 + log_dir=$6 + screen_dir=$7 + input_backend=$8 + app_arg="" + if [ "$#" -ge 9 ]; then + app_arg=$9 + fi + app_arg_flags="" + if [ -n "$app_arg" ]; then + app_arg_flags="--app-arg=$app_arg" + fi + replay_out="$remote_root/replay-$name" + rm -rf "$replay_out" "$remote_root/home-$name" + mkdir -p "$log_dir" "$screen_dir" "$remote_root/home-$name" + lib_env="$libpath" + if [ -n "${{LD_LIBRARY_PATH:-}}" ]; then + if [ -n "$lib_env" ]; then + lib_env="$lib_env:$LD_LIBRARY_PATH" + else + lib_env="$LD_LIBRARY_PATH" + fi + fi + # Read display from the current env so the parallel capture + # subshells can each target their own Xvfb. Strip the leading + # colon and any trailing .screen suffix to recover the numeric + # display index that run-ui-replay's --display flag wants. + display_num=${{DISPLAY#:}} + display_num=${{display_num%%.*}} + python3 "$repo/scripts/run-ui-replay.py" \\ + --name "$replay" \\ + --app "$app" \\ + --app-arg=-svrname --app-arg="$name" \\ + --app-arg=-geometry --app-arg=120x45+0+0 \\ + $app_arg_flags \\ + --workdir "$workdir" \\ + --replay "$repo/tests/ui/replays/$replay.replay" \\ + --out-root "$replay_out" \\ + --display "$display_num" \\ + --geometry {q(args.geometry)} \\ + --input-backend "$input_backend" \\ + --screenshot-command import \\ + --screenshot-region {q(args.screenshot_region)} \\ + --env DISPLAY="$DISPLAY" \\ + --env HOME="$remote_root/home-$name" \\ + --env LD_LIBRARY_PATH="$lib_env" \\ + --env LIBX11_COMPAT_FONT_DIR="$repo/fonts" + python3 - "$replay_out/results.tsv" "$log_dir/results.tsv" "$replay" \\ + "$screen_dir" <<'PY' +import csv +import shutil +import sys +from pathlib import Path + +src_results = Path(sys.argv[1]) +dest_results = Path(sys.argv[2]) +prefix = sys.argv[3] +screen_dir = Path(sys.argv[4]) + +with src_results.open(newline="") as f: + rows = list(csv.DictReader(f, delimiter="\\t")) + +for row in rows: + screenshot = row.get("screenshot") or "" + if not screenshot: + continue + src = Path(screenshot) + dest = screen_dir / f"{{prefix}}-{{src.name}}" + shutil.copy2(src, dest) + row["screenshot"] = str(dest) + +write_header = not dest_results.exists() +with dest_results.open("a", newline="") as f: + fields = ["status", "relative_path", "screenshot", "detail"] + writer = csv.DictWriter(f, fieldnames=fields, delimiter="\\t") + if write_header: + writer.writeheader() + for row in rows: + writer.writerow({{field: row.get(field, "") for field in fields}}) +PY + cp "$replay_out"/junit.xml "$log_dir/junit.xml" + cp "$replay_out"/logs/* "$log_dir"/ 2>/dev/null || true +}} + +{clean_remote} +rm -rf "$remote_root/screens" "$remote_root/logs" "$remote_root/diff" \\ + "$remote_root/report.tsv" "$remote_root/junit.xml" "$remote_root"/replay-* +mkdir -p "$system_build/source" "$system_logs" "$compat_logs" \\ + "$system_screens" "$compat_screens" "$remote_root/logs" + +# Wrap gcc with ccache so the system-side and compat-side gcc objects +# hit the ccache populated by the GitHub Actions cache action. Bare +# `CC=gcc` would skip the cache and recompile cold every CI run. +if command -v ccache >/dev/null 2>&1; then + if [ -d /usr/lib/ccache ]; then + export PATH="/usr/lib/ccache:$PATH" + fi + export CCACHE_DIR="${{CCACHE_DIR:-$HOME/.cache/ccache}}" +fi +cc_wrapped="gcc" + +# Fetch upstream XNEdit once before the parallel builds. +(cd "$repo" && make build/upstream/xnedit/.source-stamp) + +# Run compat-side and system-side builds concurrently. They write into +# disjoint trees ($repo/build/xnedit vs $system_build/source) and share +# only the read-only upstream tarball extraction. ccache is +# process-safe via its own locking. +compat_make_log="$remote_root/logs/compat-make.log" +: >"$compat_make_log" +( + set -e + cd "$repo" + make -j{q(args.jobs)} CC="$cc_wrapped" xnedit +) >"$compat_make_log" 2>&1 & +compat_pid=$! + +( + set -e + rm -rf "$system_build/source" + mkdir -p "$system_build/source" + tar --exclude .git --exclude '*.o' --exclude '*.a' --exclude '*.dSYM' \ + -cf - -C "$repo/build/upstream/xnedit" . | \ + tar -xf - -C "$system_build/source" + : >"$remote_root/logs/system-build.log" + # Unset MAKEFLAGS / MFLAGS so the parent build's --no-builtin-rules + # (mk/toolchain.mk) does not propagate into XNEdit's recursive make. The + # upstream util/ build relies on the built-in %.o:%.c rule for motif.o, + # whose object is in Makefile.common's OBJS but has no explicit rule in + # Makefile.dependencies. The compat side already does this in + # mk/xnedit.mk; mirror it here for the system-side build. + run_logged "$remote_root/logs/system-build.log" \ + env -u MAKEFLAGS -u MFLAGS PKG_CONFIG_PATH="" \ + make -C "$system_build/source" -j{q(args.jobs)} linux CC="$cc_wrapped" +) & +system_pid=$! + +compat_status=0 +wait "$compat_pid" || compat_status=$? +system_status=0 +wait "$system_pid" || system_status=$? + +# Surface diagnostics for any failed side before exiting; show both +# tails when both fail so the first-listed exit code does not mask a +# concurrent failure on the other side. +if [ "$compat_status" -ne 0 ]; then + echo "compat-side build failed (exit $compat_status); see $compat_make_log" >&2 + tail -60 "$compat_make_log" >&2 || true +fi +if [ "$system_status" -ne 0 ]; then + echo "system-side build failed (exit $system_status); see system-build.log" >&2 + tail -60 "$remote_root/logs/system-build.log" >&2 || true +fi +[ "$compat_status" -eq 0 ] || exit "$compat_status" +[ "$system_status" -eq 0 ] || exit "$system_status" + +test -x "$system_build/source/source/xnedit" || {{ + echo "missing system XNEdit binary" >&2 + exit 1 +}} +test -x "$repo/build/xnedit/source/source/xnedit" || {{ + echo "missing compat XNEdit binary" >&2 + exit 1 +}} + +rm -f "/tmp/.X{q(args.display)}-lock" "/tmp/.X{compat_display_num}-lock" +Xvfb "$display" -screen 0 {q(args.geometry)} >"$remote_root/xvfb-system.log" 2>&1 & +xvfb_pid=$! +Xvfb "$compat_display" -screen 0 {q(args.geometry)} >"$remote_root/xvfb-compat.log" 2>&1 & +compat_xvfb_pid=$! +trap 'kill "$xvfb_pid" "$compat_xvfb_pid" >/dev/null 2>&1 || true' EXIT + +# Wait for each Xvfb to accept connections instead of sleeping a fixed +# second. On a loaded headless runner the server can take longer than a +# second to come up, and the old `sleep 1` then raced the first xnedit +# launch and xdotool query against a display that was not listening yet, +# surfacing as a spurious capture timeout. Probe with xdotool (already a +# hard dependency above; xdpyinfo is not in the differential package set) +# and fail fast if an Xvfb died, e.g. on a stale display lock. +wait_for_display() {{ + target=$1 + server_pid=$2 + waited=0 + while [ "$waited" -lt 100 ]; do + if ! kill -0 "$server_pid" 2>/dev/null; then + echo "Xvfb for $target exited before accepting connections" >&2 + return 1 + fi + if DISPLAY="$target" xdotool getdisplaygeometry >/dev/null 2>&1; then + return 0 + fi + sleep 0.1 + waited=$((waited + 1)) + done + echo "Xvfb for $target did not become ready within 10s" >&2 + return 1 +}} +wait_for_display "$display" "$xvfb_pid" +wait_for_display "$compat_display" "$compat_xvfb_pid" + +# Run the startup replay for both sides concurrently on separate Xvfb +# instances so the capture phase scales with one side rather than both. +# Only startup is diffed; fixture is intentionally excluded here (see +# the note on the system capture below) and stays covered compat-side by +# check-smoke-xnedit. +system_cap_log="$remote_root/logs/system-capture.log" +compat_cap_log="$remote_root/logs/compat-capture.log" +: >"$system_cap_log" +: >"$compat_cap_log" +echo "xnedit differential: diffing startup + fixture" >&2 + +# Each side runs startup and opened-fixture replays in sequence on its own Xvfb; +# the two sides run concurrently. +( + set -e + export DISPLAY="$display" + capture_xnedit system-startup \ + "$system_build/source/source/xnedit" \ + "$system_build/source/source" \ + xnedit-startup-differential \ + "" \ + "$system_logs" \ + "$system_screens" \ + xdotool + capture_xnedit system-fixture \ + "$system_build/source/source/xnedit" \ + "$system_build/source/source" \ + xnedit-fixture-differential \ + "" \ + "$system_logs" \ + "$system_screens" \ + xdotool \ + "$repo/tests/ui/fixtures/xnedit-fixture.txt" +) >"$system_cap_log" 2>&1 & +system_cap_pid=$! + +( + set -e + export DISPLAY="$compat_display" + capture_xnedit compat-startup \ + "$repo/build/xnedit/source/source/xnedit" \ + "$repo/build/xnedit/source/source" \ + xnedit-startup-differential \ + "$repo/build:$repo/build/xnedit/lib-aliases" \ + "$compat_logs" \ + "$compat_screens" \ + internal + capture_xnedit compat-fixture \ + "$repo/build/xnedit/source/source/xnedit" \ + "$repo/build/xnedit/source/source" \ + xnedit-fixture-differential \ + "$repo/build:$repo/build/xnedit/lib-aliases" \ + "$compat_logs" \ + "$compat_screens" \ + internal \ + "$repo/tests/ui/fixtures/xnedit-fixture.txt" +) >"$compat_cap_log" 2>&1 & +compat_cap_pid=$! + +system_cap_status=0 +wait "$system_cap_pid" || system_cap_status=$? +compat_cap_status=0 +wait "$compat_cap_pid" || compat_cap_status=$? + +# Stage Xvfb logs and any partial replay traces into $remote_root/logs +# so the artifact upload picks them up regardless of capture success. +for replay_dir in "$remote_root"/replay-*; do + [ -d "$replay_dir" ] || continue + cp -r "$replay_dir" "$remote_root/logs/$(basename "$replay_dir")" 2>/dev/null || true +done +cp "$remote_root"/xvfb-*.log "$remote_root/logs/" 2>/dev/null || true + +if [ "$system_cap_status" -ne 0 ]; then + echo "system screenshot capture failed (exit $system_cap_status); see $system_cap_log" >&2 + tail -60 "$system_cap_log" >&2 || true +fi +if [ "$compat_cap_status" -ne 0 ]; then + echo "compat screenshot capture failed (exit $compat_cap_status); see $compat_cap_log" >&2 + tail -60 "$compat_cap_log" >&2 || true +fi +[ "$system_cap_status" -eq 0 ] || exit "$system_cap_status" +[ "$compat_cap_status" -eq 0 ] || exit "$compat_cap_status" +""" + + +def remote_compare_script(args): + # In local mode sync_repo runs the build straight from this checkout and + # never populates remote_root/repo, so resolve the compare script against + # ROOT. Only the SSH path stages a copy under remote_root/repo. + repo = str(ROOT) if args.local else f"{args.remote_root}/repo" + return f""" +set -eu +remote_root={q(args.remote_root)} +repo={q(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(remote_uri(args, f"{args.remote_root}/screens/system/"), system_dir) + rsync(remote_uri(args, f"{args.remote_root}/screens/compat/"), compat_dir) + rsync(remote_uri(args, f"{args.remote_root}/logs/"), log_dir) + if fetch_remote_compare: + rsync(remote_uri(args, f"{args.remote_root}/diff/"), diff_dir) + rsync( + remote_uri(args, f"{args.remote_root}/report.tsv"), + out_root / "report.tsv", + ) + rsync( + remote_uri(args, f"{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 XNEdit on a Linux SSH host against system libX11 and " + "libx11-compat, capture representative screenshots, and compare output." + ) + ) + parser.add_argument( + "--remote", + default=parse_env_default("XNEDIT_DIFF_REMOTE", "node11"), + ) + parser.add_argument( + "--remote-root", + default=None, + help=( + "staging directory. Precedence: CLI flag > XNEDIT_DIFF_REMOTE_ROOT " + "env > local-mode default (out_root/_work) > SSH default " + "(/tmp/libx11-compat-xnedit-differential)." + ), + ) + parser.add_argument( + "--display", + default=parse_env_default("XNEDIT_DIFF_DISPLAY", "125"), + ) + parser.add_argument( + "--geometry", + default=parse_env_default("XNEDIT_DIFF_GEOMETRY", "1280x1024x24"), + ) + parser.add_argument( + "--jobs", + default=parse_env_default("XNEDIT_DIFF_JOBS", os.environ.get("JOBS", "1")), + ) + parser.add_argument( + "--screenshot-region", + default=parse_env_default("XNEDIT_DIFF_SCREENSHOT_REGION", "0,0,1024,768"), + ) + 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( + "--local", + action="store_true", + default=parse_env_bool("XNEDIT_DIFF_LOCAL"), + help=( + "run the build / capture / compare pipeline on the local host " + "instead of SSHing to --remote. Used by the GitHub Actions " + "differential workflow." + ), + ) + parser.add_argument( + "--mae-threshold", + type=float, + default=float(parse_env_default("XNEDIT_DIFF_MAE_THRESHOLD", "0.16")), + ) + parser.add_argument( + "--changed-threshold", + type=float, + default=float(parse_env_default("XNEDIT_DIFF_CHANGED_THRESHOLD", "0.42")), + ) + parser.add_argument( + "--top", + type=int, + default=int(parse_env_default("XNEDIT_DIFF_TOP", "12")), + ) + parser.add_argument( + "--out-root", + type=Path, + default=Path(parse_env_default("XNEDIT_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=None, + ) + 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") + if not re.fullmatch(r"\d+,\d+,\d+,\d+", args.screenshot_region): + parser.error("--screenshot-region must use x,y,width,height") + + # Resolve --remote-root precedence: explicit CLI flag wins, then + # the XNEDIT_DIFF_REMOTE_ROOT env var, then the local-mode default + # (out_root/_work) or the SSH default. + if args.remote_root is None: + env_remote_root = os.environ.get("XNEDIT_DIFF_REMOTE_ROOT") + if env_remote_root: + args.remote_root = env_remote_root + elif args.local: + args.remote_root = str(args.out_root / "_work") + else: + args.remote_root = "/tmp/libx11-compat-xnedit-differential" + + # Resolve --compare-location precedence: explicit CLI flag wins, + # then the XNEDIT_DIFF_COMPARE_LOCATION env var, then the local-mode + # default (local) or the SSH default (remote). + if args.compare_location is None: + env_compare_location = os.environ.get("XNEDIT_DIFF_COMPARE_LOCATION") + if env_compare_location: + if env_compare_location not in ("remote", "local"): + parser.error("XNEDIT_DIFF_COMPARE_LOCATION must be 'remote' or 'local'") + args.compare_location = env_compare_location + elif args.local: + args.compare_location = "local" + else: + args.compare_location = "remote" + + if args.local: + # In local mode the shell payload writes under remote_root and + # fetch_results then rmtrees the matching out_root subdirs before + # syncing back. Reject overlap so a misconfigured remote_root + # cannot delete its own source tree. + try: + check_local_paths(args.out_root, Path(args.remote_root)) + except ValueError as error: + parser.error(str(error)) + + remote_repo = sync_repo(args) + remote_status = 0 + compare_status = 0 + fetch_status = 0 + try: + execute(args, remote_script(args, remote_repo)) + except subprocess.CalledProcessError as error: + remote_status = error.returncode + + if args.compare_location == "remote" and not remote_status: + try: + execute(args, remote_compare_script(args)) + except subprocess.CalledProcessError as error: + compare_status = error.returncode + + 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/src/colors.c b/src/colors.c index 52b2bc3..c096744 100644 --- a/src/colors.c +++ b/src/colors.c @@ -1,6 +1,7 @@ #include #include #include +#include #include "colors.h" #include "std-colors.h" #include "errors.h" @@ -246,6 +247,49 @@ static Bool parseHexColor(const char *spec, XColor *color) return True; } +static Bool parseRgbComponent(const char **cursor, unsigned short *value) +{ + const char *start = *cursor; + int digits = 0; + while (hexValue(start[digits]) >= 0) + digits++; + if (digits < 1 || digits > 4) + return False; + unsigned int raw = 0; + for (int i = 0; i < digits; i++) + raw = (raw << 4) | (unsigned int) hexValue(start[i]); + /* X11 rgb: components scale a k-digit value to 16 bits by bit replication, + * so rgb:f/f/f is full intensity (0xffff), not 0xf000. + */ + unsigned int maxValue = (1u << (4 * digits)) - 1u; + *value = (unsigned short) ((raw * 0xFFFFu) / maxValue); + *cursor = start + digits; + return True; +} + +static Bool parseRgbColor(const char *spec, XColor *color) +{ + if (strncmp(spec, "rgb:", 4)) + return False; + const char *cursor = spec + 4; + unsigned short red; + unsigned short green; + unsigned short blue; + if (!parseRgbComponent(&cursor, &red) || *cursor++ != '/' || + !parseRgbComponent(&cursor, &green) || *cursor++ != '/' || + !parseRgbComponent(&cursor, &blue) || *cursor != '\0') + return False; + color->red = red; + color->green = green; + color->blue = blue; + color->pixel = ((unsigned long) (red >> 8) << RED_SHIFT) | + ((unsigned long) (green >> 8) << GREEN_SHIFT) | + ((unsigned long) (blue >> 8) << BLUE_SHIFT) | + (0xFFul << ALPHA_SHIFT); + color->flags = DoRed | DoGreen | DoBlue; + return True; +} + Status XParseColor(Display *display, Colormap colormap, _Xconst char *spec, @@ -259,6 +303,8 @@ Status XParseColor(Display *display, return 0; if (parseHexColor(spec, exact_def_return)) return 1; + if (parseRgbColor(spec, exact_def_return)) + return 1; for (size_t i = 0; i < NUM_STANDARD_COLORS; i++) { if (colorNameMatches(spec, STANDARD_COLORS[i].name)) { diff --git a/src/display.c b/src/display.c index f520481..5f30e3d 100644 --- a/src/display.c +++ b/src/display.c @@ -20,6 +20,7 @@ #include #include #include +#include #ifndef SDL_HINT_VIDEO_X11_XKB #define SDL_HINT_VIDEO_X11_XKB "SDL_VIDEO_X11_XKB" @@ -56,6 +57,22 @@ static const int releaseVersion = 1; static const int supportedDepths[] = {1, 16, 24, 32}; #define COMPAT_LOGICAL_DPI 96.0f +static void applyScreenGeometryOverride(int *width, int *height) +{ + const char *value = getenv("LIBX11_COMPAT_SCREEN_GEOMETRY"); + if (!value || !*value) + return; + + int parsedWidth = 0; + int parsedHeight = 0; + char tail = '\0'; + if (sscanf(value, "%dx%d%c", &parsedWidth, &parsedHeight, &tail) == 2 && + parsedWidth > 0 && parsedHeight > 0) { + *width = parsedWidth; + *height = parsedHeight; + } +} + int XCloseDisplay(Display *display) { // https://tronche.com/gui/x/xlib/display/XCloseDisplay.html @@ -277,6 +294,7 @@ Display *XOpenDisplay(_Xconst char *display_name) return NULL; } screen->display = display; + applyScreenGeometryOverride(&displayMode.w, &displayMode.h); screen->width = displayMode.w; screen->height = displayMode.h; /* Motif converts resolution-independent dimensions from the screen's diff --git a/src/drawing.c b/src/drawing.c index 0817b07..c5ef5e1 100644 --- a/src/drawing.c +++ b/src/drawing.c @@ -554,34 +554,273 @@ void markWindowNeedsPresent(Window window) markWindowNeedsPresentRect(window, &full); } -void presentDrawableRectIfVisible(Drawable drawable, const SDL_Rect *rect) +/* Walk drawable up to its mapped top-level window, translating an optional + * child-local rect into top-level backing coordinates along the way. Every + * window in a tree shares the top-level's backing texture, so the present and + * text-stamp bookkeeping all key off this single coordinate space. + */ +static Bool drawableTopLevelRect(Drawable drawable, + const SDL_Rect *local, + Window *topWindow, + SDL_Rect *topRect) { if (!IS_TYPE(drawable, WINDOW)) - return; - - Bool haveRect = rect && rect->w > 0 && rect->h > 0; - SDL_Rect topRect = haveRect ? *rect : (SDL_Rect) {0, 0, 0, 0}; + return False; + SDL_Rect r = local ? *local : (SDL_Rect) {0, 0, 0, 0}; Window window = (Window) drawable; while (window != None && window != SCREEN_WINDOW) { if (IS_MAPPED_TOP_LEVEL_WINDOW(window)) { - WindowStruct *windowStruct = GET_WINDOW_STRUCT(window); - Bool firstPresent = !windowStruct->hasPresented; - if (haveRect) - markWindowNeedsPresentRect(window, &topRect); - else - markWindowNeedsPresent(window); - if (firstPresent) - drawWindowDataToScreen(); - return; - } - if (haveRect) { - int x = 0, y = 0; - GET_WINDOW_POS(window, x, y); - topRect.x += x; - topRect.y += y; + *topWindow = window; + *topRect = r; + return True; } + int x = 0, y = 0; + GET_WINDOW_POS(window, x, y); + r.x += x; + r.y += y; window = GET_PARENT(window); } + return False; +} + +/* text-stamp cache (see drawing.h) + */ +#define TEXT_STAMP_CAPACITY 256 + +typedef struct { + Window topWindow; + SDL_Rect topRect; + Font fontXid; + Uint32 foreground; + char *string; + Uint64 lastUsed; + Bool inUse; +} TextStampEntry; + +static TextStampEntry textStamps[TEXT_STAMP_CAPACITY]; +static Uint64 textStampClock = 0; +/* Live stamp count, so the invalidation hook on the hot present path can bail + * out before scanning when nothing uses core text (atomic read needs no lock). + */ +static SDL_atomic_t textStampActive = {0}; +static SDL_mutex *textStampMutex = NULL; +static SDL_SpinLock textStampMutexInitLock = 0; + +static SDL_mutex *textStampEnsureMutex(void) +{ + if (!textStampMutex) { + SDL_AtomicLock(&textStampMutexInitLock); + if (!textStampMutex) + textStampMutex = SDL_CreateMutex(); + SDL_AtomicUnlock(&textStampMutexInitLock); + } + return textStampMutex; +} + +static void textStampLock(void) +{ + SDL_mutex *m = textStampEnsureMutex(); + if (m) + SDL_LockMutex(m); +} + +static void textStampUnlock(void) +{ + if (textStampMutex) + SDL_UnlockMutex(textStampMutex); +} + +static void textStampEvict(TextStampEntry *entry) +{ + if (!entry->inUse) + return; + free(entry->string); + entry->string = NULL; + entry->inUse = False; + SDL_AtomicAdd(&textStampActive, -1); +} + +/* Caller holds the stamp lock. Drop every stamp on topWindow whose cell + * intersects rect, since those backing pixels were just overwritten. + */ +static void textStampInvalidateLocked(Window topWindow, const SDL_Rect *rect) +{ + for (int i = 0; i < TEXT_STAMP_CAPACITY; i++) { + if (!textStamps[i].inUse || textStamps[i].topWindow != topWindow) + continue; + SDL_Rect overlap; + if (SDL_IntersectRect(&textStamps[i].topRect, rect, &overlap)) + textStampEvict(&textStamps[i]); + } +} + +static void invalidateTextStampsTopRect(Window topWindow, const SDL_Rect *rect) +{ + if (!rect || rect->w <= 0 || rect->h <= 0) + return; + if (!SDL_AtomicGet(&textStampActive)) + return; + textStampLock(); + textStampInvalidateLocked(topWindow, rect); + textStampUnlock(); +} + +/* Drop stamps overlapping a child-local rect. Used by XClearArea (which fills + * the backing without routing through the present choke point) and by the text + * paths that overwrite a cell without recording a stamp of their own. + */ +void invalidateTextStampsForDrawableRect(Drawable drawable, + const SDL_Rect *local) +{ + Window topWindow; + SDL_Rect topRect; + if (!drawableTopLevelRect(drawable, local, &topWindow, &topRect)) + return; + invalidateTextStampsTopRect(topWindow, &topRect); +} + +Bool textStampLookup(Drawable drawable, + const SDL_Rect *cell, + Font fontXid, + Uint32 foreground, + const char *string) +{ + Window topWindow; + SDL_Rect topRect; + if (!string || !drawableTopLevelRect(drawable, cell, &topWindow, &topRect)) + return False; + Bool found = False; + textStampLock(); + for (int i = 0; i < TEXT_STAMP_CAPACITY; i++) { + TextStampEntry *e = &textStamps[i]; + if (!e->inUse || e->topWindow != topWindow || e->fontXid != fontXid || + e->foreground != foreground) + continue; + if (e->topRect.x != topRect.x || e->topRect.y != topRect.y || + e->topRect.w != topRect.w || e->topRect.h != topRect.h) + continue; + if (e->string && !strcmp(e->string, string)) { + e->lastUsed = ++textStampClock; + found = True; + break; + } + } + textStampUnlock(); + return found; +} + +void textStampRecord(Drawable drawable, + const SDL_Rect *cell, + Font fontXid, + Uint32 foreground, + const char *string) +{ + Window topWindow; + SDL_Rect topRect; + if (!string || !drawableTopLevelRect(drawable, cell, &topWindow, &topRect)) + return; + if (topRect.w <= 0 || topRect.h <= 0) + return; + char *dup = strdup(string); + if (!dup) + return; + textStampLock(); + /* This label now owns the cell; drop any stamp it overwrites, then take a + * free slot or evict the least-recently-used one. + */ + textStampInvalidateLocked(topWindow, &topRect); + int slot = -1; + Uint64 oldest = 0; + for (int i = 0; i < TEXT_STAMP_CAPACITY; i++) { + if (!textStamps[i].inUse) { + slot = i; + break; + } + if (slot < 0 || textStamps[i].lastUsed < oldest) { + oldest = textStamps[i].lastUsed; + slot = i; + } + } + if (textStamps[slot].inUse) + textStampEvict(&textStamps[slot]); + textStamps[slot].topWindow = topWindow; + textStamps[slot].topRect = topRect; + textStamps[slot].fontXid = fontXid; + textStamps[slot].foreground = foreground; + textStamps[slot].string = dup; + textStamps[slot].lastUsed = ++textStampClock; + /* Publish the count before marking the slot live so a concurrent lockless + * early-out that observes a non-zero count is guaranteed to then take the + * lock and scan this fully-populated entry. + */ + SDL_AtomicAdd(&textStampActive, 1); + textStamps[slot].inUse = True; + textStampUnlock(); +} + +void flushTextStampsForWindow(Window window) +{ + if (!SDL_AtomicGet(&textStampActive)) + return; + Window topWindow; + SDL_Rect topRect; + /* A geometry or stacking change to any window in the tree can move or + * destroy the cells we stamped, so flush the whole top-level wholesale. + */ + if (!drawableTopLevelRect(window, NULL, &topWindow, &topRect)) + topWindow = window; + textStampLock(); + for (int i = 0; i < TEXT_STAMP_CAPACITY; i++) { + if (textStamps[i].inUse && textStamps[i].topWindow == topWindow) + textStampEvict(&textStamps[i]); + } + textStampUnlock(); +} + +void freeTextStamps(void) +{ + textStampLock(); + for (int i = 0; i < TEXT_STAMP_CAPACITY; i++) { + if (textStamps[i].inUse) + textStampEvict(&textStamps[i]); + } + textStampUnlock(); +} + +static void presentDrawableRectIfVisibleEx(Drawable drawable, + const SDL_Rect *rect, + Bool invalidateStamps) +{ + Bool haveRect = rect && rect->w > 0 && rect->h > 0; + Window topWindow; + SDL_Rect topRect; + if (!drawableTopLevelRect(drawable, haveRect ? rect : NULL, &topWindow, + &topRect)) + return; + WindowStruct *windowStruct = GET_WINDOW_STRUCT(topWindow); + Bool firstPresent = !windowStruct->hasPresented; + if (haveRect) { + if (invalidateStamps) + invalidateTextStampsTopRect(topWindow, &topRect); + markWindowNeedsPresentRect(topWindow, &topRect); + } else { + if (invalidateStamps) + flushTextStampsForWindow(topWindow); + markWindowNeedsPresent(topWindow); + } + if (firstPresent) + drawWindowDataToScreen(); +} + +void presentDrawableRectIfVisible(Drawable drawable, const SDL_Rect *rect) +{ + presentDrawableRectIfVisibleEx(drawable, rect, True); +} + +void presentDrawableRectIfVisibleNoStampInvalidate(Drawable drawable, + const SDL_Rect *rect) +{ + presentDrawableRectIfVisibleEx(drawable, rect, False); } void presentDrawableIfVisible(Drawable drawable) @@ -2877,6 +3116,7 @@ int XDrawPoint(Display *display, Drawable d, GC gc, int x, int y) } clearRendererClip(renderer); shapeGuardEnd(&sg); + presentDrawableRectIfVisible(d, &pointRect); return 1; } @@ -2981,6 +3221,8 @@ int XDrawPoints(Display *display, } clearRendererClip(renderer); shapeGuardEnd(&sg); + if (haveBbox) + presentDrawableRectIfVisible(d, &pointsBbox); return 1; } @@ -3321,6 +3563,11 @@ int XClearArea(register Display *dpy, .w = clearWidth, .h = clearHeight, }; + /* The fill below overwrites these pixels, so any text stamp covering the + * cleared cell is now stale. XClearArea fills the backing without routing + * through the present choke point, so invalidate explicitly here. + */ + invalidateTextStampsForDrawableRect(w, &clearRect); Pixmap backgroundPixmap = None; unsigned long backgroundColor = 0; resolveWindowBackground(w, &backgroundPixmap, &backgroundColor); diff --git a/src/drawing.h b/src/drawing.h index 5f8195f..a273d29 100644 --- a/src/drawing.h +++ b/src/drawing.h @@ -104,6 +104,33 @@ void markWindowNeedsPresent(Window window); void markWindowNeedsPresentRect(Window window, const SDL_Rect *rect); void presentDrawableIfVisible(Drawable drawable); void presentDrawableRectIfVisible(Drawable drawable, const SDL_Rect *rect); +/* Same as presentDrawableRectIfVisible but leaves the text-stamp cache + * untouched. The core-text path calls this so marking its own freshly drawn + * cell present does not evict the stamp it is about to record. + */ +void presentDrawableRectIfVisibleNoStampInvalidate(Drawable drawable, + const SDL_Rect *rect); + +/* Text-stamp cache: records that an exact anti-aliased label (font, color, + * string) is already painted at a cell so a Motif expose/arm redraw that + * re-issues the identical XDrawString can skip the non-idempotent re-blend. + * Cells are keyed in top-level backing coordinates; any other draw over the + * region, or a structural change, drops the stamp. See src/drawing.c. + */ +Bool textStampLookup(Drawable drawable, + const SDL_Rect *cell, + Font fontXid, + Uint32 foreground, + const char *string); +void textStampRecord(Drawable drawable, + const SDL_Rect *cell, + Font fontXid, + Uint32 foreground, + const char *string); +void invalidateTextStampsForDrawableRect(Drawable drawable, + const SDL_Rect *local); +void flushTextStampsForWindow(Window window); +void freeTextStamps(void); /* Single-slot (renderer, gc, generation) cache. The shim is single * threaded and tends to issue runs of draw calls against one renderer diff --git a/src/events.c b/src/events.c index f0e7a9b..de61eff 100644 --- a/src/events.c +++ b/src/events.c @@ -1566,6 +1566,20 @@ int convertEvent(Display *display, Window sdlKeyWindow = getWindowFromId(sdlEvent->key.windowID); xEvent->xkey.root = SCREEN_WINDOW; xEvent->xkey.state = convertModifierState(XC_EVENT_KEYMOD(sdlEvent)); + /* X11 KeyCode is 8-bit, but SDL keycodes span ASCII (< 0x80) plus a + * separate 0x4000xxxx scancode range, so no collision-free mapping into + * 0..255 exists. Truncating to the low byte keeps every ASCII key on + * its natural keycode; the cost is that a scancode key whose low byte + * lands on a printable keycode aliases onto it (SDLK_EXECUTE 0x40000074 + * -> 116 + * == 't', SDLK_KP_0 0x40000062 -> 98 == 'b'). XkbKeycodeToKeysym uses + * the same truncation so decoding round-trips for the common (ASCII) + * keys. XKeysymToKeycode guards the reverse direction (it refuses a + * keycode that does not round-trip) so Motif cannot bind a special key + * onto a letter; pressing one of the rare aliasing scancode keys still + * types the colliding character, which is accepted given the 8-bit + * limit. + */ xEvent->xkey.keycode = (unsigned int) XC_EVENT_KEYSYM(sdlEvent) & 0xFF; /* Route priority for key events: * 1. Active XGrabKeyboard (modal dialogs like Motif's Help popup) @@ -1843,16 +1857,27 @@ int convertEvent(Display *display, &xEvent->xconfigure.x, &xEvent->xconfigure.y); } } - if (XC_WINDOW_SUBEVENT(sdlEvent) == SDL_WINDOWEVENT_RESIZED || - XC_WINDOW_SUBEVENT(sdlEvent) == SDL_WINDOWEVENT_SIZE_CHANGED) { + /* The X11 window and its backing are kept at the logical (point) + * size; presentation scales up to the physical surface. A RESIZED + * event carries the new logical size in data1/data2 on both SDL2 + * and SDL3. SDL3 additionally fires SDL_WINDOWEVENT_SIZE_CHANGED as + * PIXEL_SIZE_CHANGED, whose data1/data2 are physical pixels (2x on + * a HiDPI display); stamping that onto the X11 geometry would + * disagree with everything measured in logical units and break + * client layout (Motif wraps every line after a couple of + * characters). So treat only the logical resize as authoritative + * for X11 dimensions. + */ + Bool logicalResize = + XC_WINDOW_SUBEVENT(sdlEvent) == SDL_WINDOWEVENT_RESIZED; +#ifndef LIBX11_COMPAT_SDL3 + /* SDL2 SIZE_CHANGED is also in logical units. */ + logicalResize = logicalResize || XC_WINDOW_SUBEVENT(sdlEvent) == + SDL_WINDOWEVENT_SIZE_CHANGED; +#endif + if (logicalResize) { xEvent->xconfigure.width = sdlEvent->window.data1; xEvent->xconfigure.height = sdlEvent->window.data2; - /* After unification, every mapped top-level window draws into a - * per-window backing texture on the SCREEN renderer. - * SDL_GetWindowSize now reports the new size, so resize the - * backing texture to match before the next present reads at the - * new dimensions. - */ if (eventWindow != None) { GET_WINDOW_STRUCT(eventWindow)->w = (unsigned int) sdlEvent->window.data1; diff --git a/src/font.c b/src/font.c index 94a5e13..50cb88e 100644 --- a/src/font.c +++ b/src/font.c @@ -79,7 +79,10 @@ static void finishTextDamage(Display *display, { if (damage && IS_TYPE(drawable, WINDOW)) postExposeEventsForMappedChildren(display, drawable, damage, 1); - presentDrawableRectIfVisible(drawable, damage); + /* Use the stamp-preserving present: renderText has just recorded a stamp + * for this cell, and the generic present path would evict it again. + */ + presentDrawableRectIfVisibleNoStampInvalidate(drawable, damage); } /* Project-bundled "fonts" wins for self-contained checkouts; the remaining @@ -365,6 +368,7 @@ void freeFontStorage() * any retained texture from the cache would dangle. */ freeTextCache(); + freeTextStamps(); if (fontSearchPaths) { // Clear the array and free the data while (fontSearchPaths->length > 0) @@ -1045,6 +1049,32 @@ static TTF_Font *openRenderableProbeFont(const char *const *paths, return NULL; } +static Bool fontProvidesCodepoint(TTF_Font *font, Uint32 codepoint) +{ + if (!font) + return False; + return xc_TTF_GlyphIsProvidedUcs4(font, codepoint) ? True : False; +} + +static TTF_Font *openRenderableProbeFontForChar(const char *const *paths, + size_t count, + int size, + const char *skipPath, + Uint32 codepoint) +{ + 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) && fontProvidesCodepoint(font, codepoint)) + return font; + TTF_CloseFont(font); + } + return NULL; +} + static TTF_Font *openRenderableFallbackFont(const char *name, int size, const char *skipPath) @@ -1081,6 +1111,8 @@ static TTF_Font *openRenderableFallbackFont(const char *name, skipPath); if (font) return font; + if (!fontCache) + return NULL; for (size_t i = 0; i < fontCache->length; i++) { FontCacheEntry *entry = fontCache->array[i]; if (skipPath && !strcmp(entry->filePath, skipPath)) @@ -1097,16 +1129,81 @@ static TTF_Font *openRenderableFallbackFont(const char *name, return NULL; } +static TTF_Font *openRenderableFallbackFontForChar(const char *name, + int size, + const char *skipPath, + Uint32 codepoint) +{ + TTF_Font *font = NULL; + if (containsIgnoreCase(name, "times") || + containsIgnoreCase(name, "adobe-times") || + containsIgnoreCase(name, "schoolbook")) { + font = openRenderableProbeFontForChar(SERIF_PROBE_PATHS, + ARRAY_LENGTH(SERIF_PROBE_PATHS), + size, skipPath, codepoint); + 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 = openRenderableProbeFontForChar( + SANS_BOLD_PROBE_PATHS, ARRAY_LENGTH(SANS_BOLD_PROBE_PATHS), + size, skipPath, codepoint); + if (font) + return font; + } + font = openRenderableProbeFontForChar(SANS_PROBE_PATHS, + ARRAY_LENGTH(SANS_PROBE_PATHS), + size, skipPath, codepoint); + if (font) + return font; + } + font = openRenderableProbeFontForChar(MONOSPACE_PROBE_PATHS, + ARRAY_LENGTH(MONOSPACE_PROBE_PATHS), + size, skipPath, codepoint); + if (font) + return font; + if (!fontCache) + return NULL; + 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) && fontProvidesCodepoint(font, codepoint)) + return font; + TTF_CloseFont(font); + } + return NULL; +} + /* Public entry point that mirrors the per-family probe chain used by * XLoadQueryFont fallbacks (sans / sans-bold / serif / monospace). The Xft shim * routes through this so anti-aliased text rendered by client code lands on the * same TTF file the core font path would have picked. */ -struct TTF_Font *compatFontOpenFamilyFallback(const char *familyHint, int size) +TTF_Font *compatFontOpenFamilyFallback(const char *familyHint, int size) { const char *name = familyHint ? familyHint : "sans"; - return (struct TTF_Font *) openRenderableFallbackFont( - name, clampFontSize(size), NULL); + return openRenderableFallbackFont(name, clampFontSize(size), NULL); +} + +TTF_Font *compatFontOpenFamilyFallbackForChar(const char *familyHint, + int size, + Uint32 codepoint) +{ + const char *name = familyHint ? familyHint : "sans"; + return openRenderableFallbackFontForChar(name, clampFontSize(size), NULL, + codepoint); } static FontCacheEntry *findFontCacheEntryByName(const char *name) @@ -2249,6 +2346,24 @@ static Bool renderFixedBitmapText(Drawable drawable, return ok; } +static Bool renderFixedBitmapTextAndInvalidate(Drawable drawable, + SDL_Renderer *renderer, + GC gc, + int x, + int y, + const char *string, + size_t length, + SDL_Rect *drawnBounds) +{ + SDL_Rect bounds; + SDL_Rect *boundsOut = drawnBounds ? drawnBounds : &bounds; + if (!renderFixedBitmapText(drawable, renderer, gc, x, y, string, length, + boundsOut)) + return False; + invalidateTextStampsForDrawableRect(drawable, boundsOut); + return True; +} + /* 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). @@ -2284,6 +2399,51 @@ static Bool textCacheInsert(Font fontXid, return True; } +/* Stretch a blended glyph surface so its densest pixel is fully opaque while + * preserving the relative anti-aliasing gradient. FreeType/SDL_ttf builds + * differ in how much coverage a small glyph reaches: some never produce a fully + * covered pixel, so opaque text composites to gray instead of its intended + * color. Normalizing the peak to 255 keeps the core solid (opaque black stays + * black) without flattening the anti-aliased edges. A no-op when the peak is + * already 0 or 255. + */ +static void normalizeGlyphAlpha(SDL_Surface *surface) +{ + if (!surface || XC_SURFACE_BYTESPERPIXEL(surface) != 4) + return; + XcPixelFormat fmt = XC_SURFACE_FORMAT(surface); + if (SDL_MUSTLOCK(surface) && SDL_LockSurface(surface) != 0) + return; + Uint8 peak = 0; + for (int yy = 0; yy < surface->h; yy++) { + Uint32 *row = + (Uint32 *) ((Uint8 *) surface->pixels + yy * surface->pitch); + for (int xx = 0; xx < surface->w; xx++) { + Uint8 r, g, b, a; + SDL_GetRGBA(row[xx], fmt, &r, &g, &b, &a); + if (a > peak) + peak = a; + } + } + /* peak == 0 (blank) or 255 (already opaque somewhere) needs no rescale. + * Otherwise a <= peak guarantees a * 255 / peak stays within 255. + */ + if (peak != 0 && peak != 255) { + for (int yy = 0; yy < surface->h; yy++) { + Uint32 *row = + (Uint32 *) ((Uint8 *) surface->pixels + yy * surface->pitch); + for (int xx = 0; xx < surface->w; xx++) { + Uint8 r, g, b, a; + SDL_GetRGBA(row[xx], fmt, &r, &g, &b, &a); + Uint8 scaled = (Uint8) ((unsigned) a * 255u / peak); + row[xx] = SDL_MapRGBA(fmt, r, g, b, scaled); + } + } + } + if (SDL_MUSTLOCK(surface)) + SDL_UnlockSurface(surface); +} + Bool renderText(Display *display, Drawable drawable, SDL_Renderer *renderer, @@ -2321,8 +2481,8 @@ Bool renderText(Display *display, CompatFont *fontResource = GET_FONT_RESOURCE(gContext->font); Bool stringHasEmbeddedNul = strlen(string) != length; if (stringHasEmbeddedNul && fontResource && fontResource->useFixedBitmap && - renderFixedBitmapText(drawable, renderer, gc, x, y, string, length, - drawnBounds)) { + renderFixedBitmapTextAndInvalidate(drawable, renderer, gc, x, y, string, + length, drawnBounds)) { return True; } @@ -2358,12 +2518,14 @@ Bool renderText(Display *display, if (!fontSurface) { textCacheUnlock(); if (fontResource && fontResource->useFixedBitmap && - renderFixedBitmapText(drawable, renderer, gc, x, y, string, - length, drawnBounds)) { + renderFixedBitmapTextAndInvalidate(drawable, renderer, gc, x, y, + string, length, + drawnBounds)) { return True; } return False; } + normalizeGlyphAlpha(fontSurface); textureWidth = fontSurface->w; textureHeight = fontSurface->h; fontTexture = SDL_CreateTextureFromSurface(renderer, fontSurface); @@ -2371,8 +2533,9 @@ Bool renderText(Display *display, if (!fontTexture) { textCacheUnlock(); if (fontResource && fontResource->useFixedBitmap && - renderFixedBitmapText(drawable, renderer, gc, x, y, string, - length, drawnBounds)) { + renderFixedBitmapTextAndInvalidate(drawable, renderer, gc, x, y, + string, length, + drawnBounds)) { return True; } return False; @@ -2411,6 +2574,25 @@ Bool renderText(Display *display, } if (drawnBounds) *drawnBounds = destR; + /* Anti-aliased glyph edges are not idempotent under SDL alpha blending, so + * Motif's expose/arm redraws (an identical XDrawString to the same cell + * with no interior clear) would thicken the text on every pass. When the + * draw is an unclipped GXcopy of NUL-free text, skip the re-blit if the + * identical label is already stamped at this cell, and record a stamp once + * it is drawn. Clipped draws and other raster ops are left untouched, as + * they are not safely idempotent. Any other draw over the cell, or a + * structural change, drops the stamp (see drawing.c). + */ + Bool idempotent = !stringHasEmbeddedNul && gContext->function == GXcopy && + gContext->clipMask == None && + !gContext->clipRectanglesSet; + if (idempotent && textStampLookup(drawable, &destR, gContext->font, + (Uint32) foreground, string)) { + textCacheUnlock(); + if (textureOwned) + SDL_DestroyTexture(fontTexture); + return True; + } ShapeGuard sg; shapeGuardBegin(&sg, drawable, renderer, &destR); int clipCount = getGcClipIterationCount(gc, drawable); @@ -2428,6 +2610,18 @@ Bool renderText(Display *display, textCacheUnlock(); if (textureOwned) SDL_DestroyTexture(fontTexture); + if (ok) { + /* finishTextDamage marks this cell present without touching stamps, so + * keep the stamp set consistent here: record the freshly drawn label + * when it is safely idempotent, otherwise drop any stamp this draw + * overwrote. + */ + if (idempotent) + textStampRecord(drawable, &destR, gContext->font, + (Uint32) foreground, string); + else + invalidateTextStampsForDrawableRect(drawable, &destR); + } if (!ok && drawnBounds) drawnBounds->w = drawnBounds->h = 0; return ok; @@ -2529,6 +2723,11 @@ static int drawImageString(Display *display, */ SDL_Rect imageDamage; unionRect(&background, &damage, &imageDamage); + /* The opaque background fill plus text overwrote this region without + * recording a stamp, so drop any stamp it covered before the + * stamp-preserving present in finishTextDamage runs. + */ + invalidateTextStampsForDrawableRect(drawable, &imageDamage); finishTextDamage(display, drawable, &imageDamage); } return result; diff --git a/src/font.h b/src/font.h index e92917b..7cbac3a 100644 --- a/src/font.h +++ b/src/font.h @@ -2,9 +2,7 @@ #define FONT_H #include -#include "sdl-compat.h" - -struct TTF_Font; +#include "sdl-ttf-compat.h" extern void freeFontStorage(void); Bool initFontStorage(void); @@ -17,7 +15,10 @@ Bool initFontStorage(void); * applies. Returns a TTF_Font * the caller must TTF_CloseFont, or * NULL when no probe path is openable on this host. */ -struct TTF_Font *compatFontOpenFamilyFallback(const char *familyHint, int size); +TTF_Font *compatFontOpenFamilyFallback(const char *familyHint, int size); +TTF_Font *compatFontOpenFamilyFallbackForChar(const char *familyHint, + int size, + Uint32 codepoint); Bool compatFontIsClientUsable(Font fontXid); Bool compatFontRetainForGC(Font fontXid); diff --git a/src/input-method.c b/src/input-method.c index ade8933..eca2669 100644 --- a/src/input-method.c +++ b/src/input-method.c @@ -1,4 +1,5 @@ #include "input-method.h" +#include #include #include #include @@ -15,6 +16,86 @@ static char *currLocaleModifierList = defaultLocaleModifierList; char *pendingText = NULL; +/* Encode a Latin-1/ASCII keysym as UTF-8. + * + * Returns the number of bytes the encoding needs (0 if the keysym is not + * encodable). Writes into buffer only when it fits, so a return value greater + * than nbytes means the caller must report XBufferOverflow rather than treating + * the keysym as undecodable. + */ +static int appendKeysymUtf8(KeySym keysym, char *buffer, int nbytes) +{ + unsigned int codepoint; + switch (keysym) { + case XK_BackSpace: + codepoint = '\b'; + break; + case XK_Tab: + codepoint = '\t'; + break; + case XK_Linefeed: + codepoint = '\n'; + break; + case XK_Return: + case XK_KP_Enter: + codepoint = '\r'; + break; + case XK_Escape: + codepoint = 0x1b; + break; + case XK_Delete: + codepoint = 0x7f; + break; + default: + if (keysym >= XK_space && keysym <= XK_asciitilde) + codepoint = (unsigned int) keysym; + else if (keysym >= XK_nobreakspace && keysym <= XK_ydiaeresis) + codepoint = (unsigned int) (keysym & 0xff); + else if (((unsigned long) keysym & 0xff000000UL) == 0x01000000UL) + /* X11 Unicode keysyms: 0x01000000 | codepoint. */ + codepoint = (unsigned int) ((unsigned long) keysym & 0x00ffffffUL); + else + return 0; + break; + } + + int needed; + if (codepoint < 0x80) + needed = 1; + else if (codepoint < 0x800) + needed = 2; + else if (codepoint < 0x10000) + needed = 3; + else if (codepoint <= 0x10ffff) + needed = 4; + else + return 0; + + if (!buffer || nbytes < needed) + return needed; + switch (needed) { + case 1: + buffer[0] = (char) codepoint; + break; + case 2: + buffer[0] = (char) (0xc0 | (codepoint >> 6)); + buffer[1] = (char) (0x80 | (codepoint & 0x3f)); + break; + case 3: + buffer[0] = (char) (0xe0 | (codepoint >> 12)); + buffer[1] = (char) (0x80 | ((codepoint >> 6) & 0x3f)); + buffer[2] = (char) (0x80 | (codepoint & 0x3f)); + break; + default: + buffer[0] = (char) (0xf0 | (codepoint >> 18)); + buffer[1] = (char) (0x80 | ((codepoint >> 12) & 0x3f)); + buffer[2] = (char) (0x80 | ((codepoint >> 6) & 0x3f)); + buffer[3] = (char) (0x80 | (codepoint & 0x3f)); + break; + } + return needed; +} + void inputMethodSetCurrentText(char *text) { pendingText = text; @@ -26,9 +107,9 @@ KeySym getKeySymForChar(char c) char character; KeySym keySym; } charMapping[] = { - {' ', XK_space}, {'\n', XK_Return}, {'\r', XK_Linefeed}, + {' ', XK_space}, {'\n', XK_Linefeed}, {'\r', XK_Return}, {'\t', XK_Tab}, {'\b', XK_BackSpace}, {'-', XK_minus}, - {'+', XK_plus}, {'#', XK_numbersign}, {'*', XK_multiply}, + {'+', XK_plus}, {'#', XK_numbersign}, {'*', XK_asterisk}, {'~', XK_asciitilde}, {'\'', XK_quoteright}, {'"', XK_quotedbl}, {'!', XK_exclam}, {'@', XK_at}, {'%', XK_percent}, {'&', XK_ampersand}, {'$', XK_dollar}, {'/', XK_slash}, @@ -709,39 +790,77 @@ int Xutf8LookupString(XIC inputConnection, Status *status_return) { // http://www.x.org/archive/X11R7.6/doc/man/man3/Xutf8LookupString.3.xhtml + if (!event) { + if (status_return) + *status_return = XLookupNone; + return 0; + } if (event->keycode == 0) { if (!pendingText) { - *status_return = XLookupNone; + if (status_return) + *status_return = XLookupNone; return 0; } LOG("InputMethod Event! text = '%s'.\n", pendingText); - int textLen = strlen(pendingText) + 1; - if (textLen > bytes_buffer) { - *status_return = XBufferOverflow; - return textLen; + /* Xutf8LookupString returns a byte count and an unterminated string, so + * the NUL is excluded from the length, the buffer check, and the copy. + * size_t throughout: narrowing strlen to int first would let a >INT_MAX + * commit wrap negative, slip past the bounds check, and turn the copy + * into a huge memcpy. + */ + size_t textLen = strlen(pendingText); + if (!buffer_return || bytes_buffer < 0 || + textLen > (size_t) bytes_buffer) { + if (status_return) + *status_return = XBufferOverflow; + return textLen > (size_t) INT_MAX ? INT_MAX : (int) textLen; } - if (textLen > 0) { - *status_return = XLookupBoth; - *keysym_return = getKeySymForChar(pendingText[textLen - 2]); + /* A single ASCII byte maps to a keysym (XLookupBoth); a multi-byte or + * multi-character commit is text only, so report XLookupChars with no + * keysym rather than deriving one from a UTF-8 continuation byte. + */ + if (textLen == 1) { + if (status_return) + *status_return = XLookupBoth; + if (keysym_return) + *keysym_return = getKeySymForChar(pendingText[0]); } else { - *status_return = XLookupChars; + if (status_return) + *status_return = XLookupChars; + if (keysym_return) + *keysym_return = NoSymbol; } memcpy(buffer_return, pendingText, textLen); pendingText = NULL; - return textLen; - } else { - LOG("Normal Event, Keycode = %d, '%c'\n", event->keycode, - event->keycode); - if (event->keycode <= 127) { - *status_return = XLookupBoth; - *buffer_return = event->keycode; - } else { + return (int) textLen; + } + + LOG("Normal Event, Keycode = %d, '%c'\n", event->keycode, event->keycode); + unsigned int consumedModifiers = 0; + KeySym keysym = NoSymbol; + XkbLookupKeySym(event->display, event->keycode, event->state, + &consumedModifiers, &keysym); + int needed = appendKeysymUtf8(keysym, buffer_return, bytes_buffer); + if (keysym_return) + *keysym_return = keysym; + if (needed > 0 && (!buffer_return || needed > bytes_buffer)) { + /* Encodable keysym but there is nowhere to put it (no buffer or it is + * too small): report the required size instead of claiming a write that + * did not happen. + */ + if (status_return) + *status_return = XBufferOverflow; + return needed; + } + if (status_return) { + if (keysym == NoSymbol && needed == 0) + *status_return = XLookupNone; + else if (needed == 0) *status_return = XLookupKeySym; - } - *keysym_return = - XkbKeycodeToKeysym(event->display, event->keycode, 0, 0); - return 1; + else + *status_return = XLookupBoth; } + return needed; } Bool XRegisterIMInstantiateCallback(Display *display, diff --git a/src/input.c b/src/input.c index 30e91fc..5ea1d47 100644 --- a/src/input.c +++ b/src/input.c @@ -194,6 +194,52 @@ KeySym *XGetKeyboardMapping(Display *display, return mapping; } +/* Single source of truth for the US-layout Shift pairs on the number / + * punctuation rows. The forward (shiftedKeysym) and reverse + * (unshiftedPunctuation) lookups both scan this table, so the two directions + * cannot drift. Letters (a-z <-> A-Z) are handled by range arithmetic at the + * call sites, not here. + */ +static const struct { + KeySym base; + KeySym shifted; +} usShiftPairs[] = { + {XK_1, XK_exclam}, + {XK_2, XK_at}, + {XK_3, XK_numbersign}, + {XK_4, XK_dollar}, + {XK_5, XK_percent}, + {XK_6, XK_asciicircum}, + {XK_7, XK_ampersand}, + {XK_8, XK_asterisk}, + {XK_9, XK_parenleft}, + {XK_0, XK_parenright}, + {XK_grave, XK_asciitilde}, + {XK_minus, XK_underscore}, + {XK_equal, XK_plus}, + {XK_bracketleft, XK_braceleft}, + {XK_bracketright, XK_braceright}, + {XK_backslash, XK_bar}, + {XK_semicolon, XK_colon}, + {XK_apostrophe, XK_quotedbl}, + {XK_comma, XK_less}, + {XK_period, XK_greater}, + {XK_slash, XK_question}, +}; + +/* The unshifted base keysym a shifted symbol is produced from, or NoSymbol. + * Keeps the reverse lookups (keycode + modifiers) in agreement with + * shiftedKeysym(); without it, accelerators bound on a shifted-symbol keysym + * would register the wrong key. + */ +static KeySym unshiftedPunctuation(KeySym keysym) +{ + for (size_t i = 0; i < sizeof(usShiftPairs) / sizeof(usShiftPairs[0]); i++) + if (usShiftPairs[i].shifted == keysym) + return usShiftPairs[i].base; + return NoSymbol; +} + KeyCode XKeysymToKeycode(Display *display, KeySym keysym) { // https://tronche.com/gui/x/xlib/utilities/keyboard/XKeysymToKeycode.html @@ -203,9 +249,29 @@ KeyCode XKeysymToKeycode(Display *display, KeySym keysym) return SDLK_a + (keysym - XK_a); if (keysym >= XK_A && keysym <= XK_Z) return SDLK_a + (keysym - XK_A); + KeySym base = unshiftedPunctuation(keysym); + if (base != NoSymbol) + return XKeysymToKeycode(display, base); + /* Reverse scan: when several SDL keys carry the same keysym (e.g. + * SDLK_RETURN and SDLK_RETURN2 both map to XK_Return) the highest-index + * entry wins, so the returned keycode is stable but not necessarily the + * primary key's. That is fine here because only round-tripping and + * collision-freedom matter, not which physical key the keycode names; do + * not "simplify" the iteration order without preserving that property. + */ for (int i = SDL_KEYCODE_TO_KEYSYM_LENGTH - 1; i >= 0; i--) { - if (SDLKeycodeToKeySym[i].keysym == keysym) - return SDLKeycodeToKeySym[i].keycode & 0xFF; + if (SDLKeycodeToKeySym[i].keysym != keysym) + continue; + KeyCode kc = SDLKeycodeToKeySym[i].keycode & 0xFF; + /* X keycodes are the low byte of the SDL keycode, so a 0x4000xxxx + * scancode key can alias onto an ASCII key: SDLK_EXECUTE (0x40000074) + * truncates to 116, exactly SDLK_t. Returning that keycode would make + * Motif bind the special key (e.g. osfActivate/Execute) onto the 't' + * key, so typing 't' fires a newline. Only accept a keycode that maps + * back to this keysym; otherwise it belongs to the ASCII key. + */ + if (XkbKeycodeToKeysym(display, kc, 0, 0) == keysym) + return kc; } LOG("%s: Got unimplemented keysym %lu\n", __func__, keysym); return 0; @@ -287,6 +353,8 @@ unsigned int XkbKeysymToModifiers(Display *display, KeySym keysym) (void) display; if (keysym >= XK_A && keysym <= XK_Z) return ShiftMask; + if (unshiftedPunctuation(keysym) != NoSymbol) + return ShiftMask; switch (keysym) { case XK_Shift_L: case XK_Shift_R: @@ -313,6 +381,21 @@ unsigned int XkbKeysymToModifiers(Display *display, KeySym keysym) return 0; } +/* Map an unshifted keysym to its Shift counterpart on a US layout. SDL reports + * the modifier and the base key separately, so the shifted symbol (1 -> !, + * - -> _, etc.) has to be synthesized here; without it Shift only upper-cased + * letters and digits/punctuation came through unshifted. + */ +static KeySym shiftedKeysym(KeySym keysym) +{ + if (keysym >= XK_a && keysym <= XK_z) + return XK_A + (keysym - XK_a); + for (size_t i = 0; i < sizeof(usShiftPairs) / sizeof(usShiftPairs[0]); i++) + if (usShiftPairs[i].base == keysym) + return usShiftPairs[i].shifted; + return keysym; +} + Bool XkbLookupKeySym(Display *display, KeyCode keycode, unsigned int modifiers, @@ -321,9 +404,12 @@ Bool XkbLookupKeySym(Display *display, { KeySym keysym = XkbKeycodeToKeysym(display, keycode, 0, 0); unsigned int consumedModifiers = 0; - if ((modifiers & ShiftMask) && keysym >= XK_a && keysym <= XK_z) { - keysym = XK_A + (keysym - XK_a); - consumedModifiers |= ShiftMask; + if (modifiers & ShiftMask) { + KeySym shifted = shiftedKeysym(keysym); + if (shifted != keysym) { + keysym = shifted; + consumedModifiers |= ShiftMask; + } } if (modifiers_return) *modifiers_return = consumedModifiers; @@ -332,6 +418,27 @@ Bool XkbLookupKeySym(Display *display, return keysym != NoSymbol; } +static int controlByteForKeysym(KeySym keysym) +{ + switch (keysym) { + case XK_BackSpace: + return '\b'; + case XK_Tab: + return '\t'; + case XK_Linefeed: + return '\n'; + case XK_Return: + case XK_KP_Enter: + return '\r'; + case XK_Escape: + return 0x1b; + case XK_Delete: + return 0x7f; + default: + return -1; + } +} + int XLookupString(XKeyEvent *event_struct, char *buffer_return, int bytes_buffer, @@ -339,12 +446,35 @@ int XLookupString(XKeyEvent *event_struct, XComposeStatus *status_in_out) { // https://tronche.com/gui/x/xlib/utilities/XLookupString.html - if (buffer_return) - *buffer_return = event_struct->keycode; + (void) status_in_out; + if (!event_struct) + return 0; + unsigned int consumedModifiers = 0; + KeySym keysym = NoSymbol; + if (!XkbLookupKeySym(event_struct->display, event_struct->keycode, + event_struct->state, &consumedModifiers, &keysym)) { + if (keysym_return) + *keysym_return = NoSymbol; + return 0; + } if (keysym_return) - *keysym_return = XkbKeycodeToKeysym(event_struct->display, - event_struct->keycode, 0, 0); - return 1; + *keysym_return = keysym; + if (!buffer_return || bytes_buffer <= 0) + return 0; + int controlByte = controlByteForKeysym(keysym); + if (controlByte >= 0) { + buffer_return[0] = (char) controlByte; + return 1; + } + if (keysym >= XK_space && keysym <= XK_asciitilde) { + buffer_return[0] = (char) keysym; + return 1; + } + if (keysym >= XK_nobreakspace && keysym <= XK_ydiaeresis) { + buffer_return[0] = (char) (keysym & 0xff); + return 1; + } + return 0; } XModifierKeymap *XGetModifierMapping(Display *display) diff --git a/src/missing.c b/src/missing.c index 389b5b6..e31d105 100644 --- a/src/missing.c +++ b/src/missing.c @@ -314,7 +314,9 @@ static int do_locale_lookup(XKeyEvent *ev, KeySym keysym = NoSymbol; int produced = 0; if (ev && ev->type == KeyPress) { - keysym = XkbKeycodeToKeysym(ev->display, ev->keycode, 0, 0); + unsigned int consumedModifiers = 0; + XkbLookupKeySym(ev->display, ev->keycode, ev->state, &consumedModifiers, + &keysym); if (buffer && nbytes > 0) produced = keysym_to_utf8(keysym, buffer, nbytes); } diff --git a/src/sdl-compat.h b/src/sdl-compat.h index 29828e9..809a148 100644 --- a/src/sdl-compat.h +++ b/src/sdl-compat.h @@ -692,6 +692,7 @@ static inline int xc_GetDesktopDisplayMode(int displayIndex, case SDL_EVENT_WINDOW_FIRST ... SDL_EVENT_WINDOW_LAST #define XC_EVENT_KEYSYM(ev) ((ev)->key.key) #define XC_EVENT_KEYMOD(ev) ((ev)->key.mod) +#define XC_EVENT_SET_KEYMOD(ev, v) ((ev)->key.mod = (v)) #define XC_EVENT_SET_KEYSYM(ev, v) ((ev)->key.key = (v)) #define XC_EVENT_SET_SCANCODE(ev, v) ((ev)->key.scancode = (v)) #define XC_EVENT_SET_KEY_PRESSED(ev, p) ((ev)->key.down = (p)) @@ -740,6 +741,7 @@ typedef SDL_PixelFormat *XcPixelFormat; #define XC_CASE_WINDOWEVENT case SDL_WINDOWEVENT #define XC_EVENT_KEYSYM(ev) ((ev)->key.keysym.sym) #define XC_EVENT_KEYMOD(ev) ((ev)->key.keysym.mod) +#define XC_EVENT_SET_KEYMOD(ev, v) ((ev)->key.keysym.mod = (v)) #define XC_EVENT_SET_KEYSYM(ev, v) ((ev)->key.keysym.sym = (v)) #define XC_EVENT_SET_SCANCODE(ev, v) ((ev)->key.keysym.scancode = (v)) #define XC_EVENT_SET_KEY_PRESSED(ev, p) \ diff --git a/src/sdl-ttf-compat.h b/src/sdl-ttf-compat.h index 83c0ef2..df212f2 100644 --- a/src/sdl-ttf-compat.h +++ b/src/sdl-ttf-compat.h @@ -73,4 +73,25 @@ static inline SDL_Surface *xc_TTF_RenderUTF8_Blended(TTF_Font *font, #endif /* LIBX11_COMPAT_SDL3 */ +#ifndef SDL_TTF_VERSION_ATLEAST +#define SDL_TTF_VERSION_ATLEAST(x, y, z) \ + ((SDL_TTF_MAJOR_VERSION >= (x)) && \ + (SDL_TTF_MAJOR_VERSION > (x) || SDL_TTF_MINOR_VERSION >= (y)) && \ + (SDL_TTF_MAJOR_VERSION > (x) || SDL_TTF_MINOR_VERSION > (y) || \ + SDL_TTF_PATCHLEVEL >= (z))) +#endif + +static inline int xc_TTF_GlyphIsProvidedUcs4(TTF_Font *font, Uint32 ch) +{ +#ifdef LIBX11_COMPAT_SDL3 + return TTF_FontHasGlyph(font, ch); +#elif SDL_TTF_VERSION_ATLEAST(2, 0, 18) + return TTF_GlyphIsProvided32(font, ch); +#else + if (ch > 0xffff) + return 0; + return TTF_GlyphIsProvided(font, (Uint16) ch); +#endif +} + #endif diff --git a/src/snapshot.c b/src/snapshot.c index c8d2fd6..c0a36a4 100644 --- a/src/snapshot.c +++ b/src/snapshot.c @@ -194,6 +194,7 @@ int snapshotHandleEvent(const SDL_Event *event) } char *path = env->path; int rc = 0; + SDL_Surface *ownedSurface = NULL; Uint32 winId = replayTargetWindowId(); SDL_Window *win = (winId != 0) ? SDL_GetWindowFromID(winId) : NULL; if (!win) { @@ -205,8 +206,26 @@ int snapshotHandleEvent(const SDL_Event *event) SDL_Surface *surface = SDL_GetWindowSurface(win); if (!surface) { LOG("snapshot: SDL_GetWindowSurface failed: %s\n", SDL_GetError()); - rc = -3; - goto signal; + Window xwin = getWindowFromId(winId); + if (xwin != None) { + SDL_Renderer *renderer = getWindowRenderer(xwin); + WindowStruct *windowStruct = GET_WINDOW_STRUCT(xwin); + if (windowStruct) { + SDL_Rect rect = { + .x = 0, + .y = 0, + .w = (int) windowStruct->w, + .h = (int) windowStruct->h, + }; + surface = getRenderSurfaceRect(renderer, &rect); + } + ownedSurface = surface; + } + if (!surface) { + LOG("snapshot: render-surface fallback failed\n"); + rc = -3; + goto signal; + } } /* SDL_SaveBMP writes incrementally to the open file, so a runner that polls @@ -245,6 +264,8 @@ int snapshotHandleEvent(const SDL_Event *event) free(tmpPath); LOG("snapshot: wrote %s (%dx%d)\n", path, surface->w, surface->h); signal: + if (ownedSurface) + SDL_FreeSurface(ownedSurface); signalSnapshotResult(env->generation, rc); free(path); free(env); diff --git a/src/window-internal.c b/src/window-internal.c index e4201e3..1e533c3 100644 --- a/src/window-internal.c +++ b/src/window-internal.c @@ -155,6 +155,11 @@ void destroyScreenWindow(Display *display) WindowStruct *windowStruct = GET_WINDOW_STRUCT(SCREEN_WINDOW); for (i = 0; i < windowStruct->children.length; i++) destroyWindow(display, children[i], False); + /* A grab taken directly on the root survives the child teardown above, + * so drop it before SCREEN_WINDOW goes away to avoid stale grab state + * across a display close/reopen. + */ + releaseActiveGrabsForUnviewableWindow(display, SCREEN_WINDOW); invalidatePutImageStagingTexture(windowStruct->sdlRenderer); invalidateTextCacheForRenderer(windowStruct->sdlRenderer); SDL_DestroyRenderer(windowStruct->sdlRenderer); @@ -465,8 +470,29 @@ void removeChildFromParent(Window child) } } +/* X releases an active pointer or keyboard grab when its grab window becomes + * unviewable. Mirror that on unmap and destroy: a Motif menu grabs the pointer + * on its override-redirect shell, and when the shell is unmapped on selection + * the grab must drop, or every later pointer event routes to the now-invisible + * shell and the application appears frozen. The grab window is unviewable when + * the window losing viewability is the grab window itself or one of its + * ancestors. + */ +void releaseActiveGrabsForUnviewableWindow(Display *display, Window window) +{ + Window pointerGrabWindow = getGrabbedPointerWindow(); + if (pointerGrabWindow != None && + (pointerGrabWindow == window || isParent(window, pointerGrabWindow))) + XUngrabPointer(display, CurrentTime); + Window keyboardGrabWindow = getGrabbedKeyboardWindow(); + if (keyboardGrabWindow != None && + (keyboardGrabWindow == window || isParent(window, keyboardGrabWindow))) + XUngrabKeyboard(display, CurrentTime); +} + void destroyWindow(Display *display, Window window, Bool freeParentData) { + flushTextStampsForWindow(window); /* Drain pre-cascade stale events for this window FIRST, before any * recursion. A focused descendant whose revert lands on this window would * otherwise queue FocusIn(window) during the recursion, and a naive discard @@ -492,6 +518,10 @@ void destroyWindow(Display *display, Window window, Bool freeParentData) * route events through a stale grab entry. */ releaseButtonGrabsForWindow(window); + /* A destroyed window is unviewable, so release any active pointer/keyboard + * grab it held for the same reason XUnmapWindow does. + */ + releaseActiveGrabsForUnviewableWindow(display, 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 @@ -1237,6 +1267,10 @@ void freeWindowProperty(WindowProperty *property) void resizeWindowTexture(Window window) { + /* The backing is about to be recreated and re-cleared, dropping every + * painted cell, so discard the stamps that tracked them. + */ + flushTextStampsForWindow(window); WindowStruct *windowStruct = GET_WINDOW_STRUCT(window); if (!windowStruct->sdlTexture) return; @@ -1370,6 +1404,10 @@ Bool configureWindow(Display *display, { if (window == SCREEN_WINDOW) return True; + /* A move, resize, or restack relocates this window's cells in the shared + * top-level backing, so any text stamps recorded for them are now stale. + */ + flushTextStampsForWindow(window); Bool hasChanged = False; WindowStruct *windowStruct = GET_WINDOW_STRUCT(window); diff --git a/src/window-internal.h b/src/window-internal.h index a9b7f0e..b45aa8c 100644 --- a/src/window-internal.h +++ b/src/window-internal.h @@ -55,6 +55,7 @@ void topLevelWindowLogicalPosition(Window window, int *logicalX, int *logicalY); Bool isParent(Window window1, Window window2); +void releaseActiveGrabsForUnviewableWindow(Display *display, Window window); Bool isWindowEffectivelyViewable(Window window); WindowProperty *findProperty(Array *properties, Atom property, size_t *index); void freeWindowProperty(WindowProperty *property); diff --git a/src/window.c b/src/window.c index b0b9d96..00f6a31 100644 --- a/src/window.c +++ b/src/window.c @@ -551,6 +551,12 @@ int XUnmapWindow(Display *display, Window window) return 1; windowStruct->mapState = UnMapped; + flushTextStampsForWindow(window); + /* The window is now unviewable; drop any active grab it (or an ancestor) + * held so a Motif menu shell does not keep swallowing pointer input after + * it unmaps. + */ + releaseActiveGrabsForUnviewableWindow(display, window); /* Unmapping clears this window's contribution to the sibling occlusion * graph; lower siblings may now have more visible area. Mark the top-level * subtree stale so the next draw recomputes. @@ -715,6 +721,11 @@ int XReparentWindow(Display *display, * cannot fail under OOM. */ int oldX = windowStruct->x, oldY = windowStruct->y; + /* Reparenting relocates the window's cells in (and possibly between) shared + * top-level backings, so drop its text stamps for both the old and the new + * top-level. Flush before detach to resolve the old top-level. + */ + flushTextStampsForWindow(window); removeChildFromParent(window); if (!addChildToWindow(parent, window)) { LOG("Out of memory: Failed to reattach window in XReparentWindow!\n"); @@ -723,6 +734,7 @@ int XReparentWindow(Display *display, } windowStruct->x = x; windowStruct->y = y; + flushTextStampsForWindow(window); /* The new parent's siblings of "window" (and their descendants) and * "window"'s own descendants all need their cached visible regions * recomputed against the new parent chain. removeChildFromParent above @@ -730,6 +742,11 @@ int XReparentWindow(Display *display, * one. */ invalidateVisibleRegionForTopLevel(window); + /* Reparenting a mapped window under an unmapped parent makes it unviewable + * without an XUnmapWindow, so release any active grab it held here too. + */ + if (mapState != UnMapped && !isWindowEffectivelyViewable(window)) + releaseActiveGrabsForUnviewableWindow(display, window); if (mapState != UnMapped) { if (wasTopLevel && parent != SCREEN_WINDOW) { if (!mergeWindowDrawables(parent, window)) { diff --git a/src/wrapper/sdl-ttf-wrapper.c b/src/wrapper/sdl-ttf-wrapper.c index 46c605d..9cae211 100644 --- a/src/wrapper/sdl-ttf-wrapper.c +++ b/src/wrapper/sdl-ttf-wrapper.c @@ -59,11 +59,18 @@ TTF_WRAP(const char *, TTF_GetError, (void), ()) TTF_WRAP(int, TTF_GetFontStyle, (const TTF_Font *font), (font)) #if SDL_TTF_VERSION_ATLEAST(2, 20, 0) TTF_WRAP(int, TTF_GlyphIsProvided, (TTF_Font * font, Uint16 ch), (font, ch)) +TTF_WRAP(int, TTF_GlyphIsProvided32, (TTF_Font * font, Uint32 ch), (font, ch)) #else TTF_WRAP(int, TTF_GlyphIsProvided, (const TTF_Font *font, Uint16 ch), (font, ch)) +#if SDL_TTF_VERSION_ATLEAST(2, 0, 18) +TTF_WRAP(int, + TTF_GlyphIsProvided32, + (const TTF_Font *font, Uint32 ch), + (font, ch)) +#endif #endif TTF_WRAP(int, TTF_GlyphMetrics, diff --git a/src/xft.c b/src/xft.c index dc7fe9e..65b07a5 100644 --- a/src/xft.c +++ b/src/xft.c @@ -25,9 +25,50 @@ typedef struct { int i; double d; FcBool b; + FcCharSet *c; } value; } FcPatternEntry; +struct _FcCharSet { + FcChar32 *chars; + int length; + int capacity; + FcBool universal; +}; + +struct _FcObjectSet { + char **objects; + int length; + int capacity; +}; + +static FcCharSet *copyCharSet(const FcCharSet *src) +{ + FcCharSet *dst = calloc(1, sizeof(FcCharSet)); + if (!dst) + return NULL; + if (!src) { + dst->universal = FcTrue; + return dst; + } + dst->universal = src->universal; + if (src->length == 0) + return dst; + if (src->length < 0 || (size_t) src->length > SIZE_MAX / sizeof(FcChar32)) { + free(dst); + return NULL; + } + dst->chars = malloc(sizeof(FcChar32) * (size_t) src->length); + if (!dst->chars) { + free(dst); + return NULL; + } + memcpy(dst->chars, src->chars, sizeof(FcChar32) * (size_t) src->length); + dst->length = src->length; + dst->capacity = src->length; + return dst; +} + struct _FcPattern { FcPatternEntry *entries; int length; @@ -41,11 +82,39 @@ struct _FcPattern { int refcount; }; +static FcPattern *makeFontPattern(const char *family, + const char *style, + FcBool monospace) +{ + FcPattern *pattern = FcPatternCreate(); + if (!pattern) + return NULL; + if (!FcPatternAddString(pattern, FC_FAMILY, (const FcChar8 *) family) || + !FcPatternAddString(pattern, FC_STYLE, (const FcChar8 *) style) || + !FcPatternAddString(pattern, FC_FULLNAME, (const FcChar8 *) family) || + !FcPatternAddBool(pattern, FC_SCALABLE, FcTrue) || + !FcPatternAddInteger(pattern, FC_SPACING, + monospace ? FC_MONO : FC_PROPORTIONAL)) { + FcPatternDestroy(pattern); + return NULL; + } + return pattern; +} + +typedef struct { + int x; + int y; + int width; + int height; +} XftClipRect; + struct _XftDraw { Display *display; Drawable drawable; Visual *visual; Colormap colormap; + XftClipRect *clipRects; + int clipRectCount; }; typedef struct { @@ -107,37 +176,57 @@ static FcBool reservePatternEntry(FcPattern *pattern) static FcBool addPatternEntry(FcPattern *pattern, const char *object, FcType type, - const void *value) + const void *value, + FcBool append) { if (!pattern || !object || !reservePatternEntry(pattern)) return FcFalse; - FcPatternEntry *entry = &pattern->entries[pattern->length]; - memset(entry, 0, sizeof(*entry)); - entry->object = xftStrdup(object); - if (!entry->object) + int index = pattern->length; + if (!append) { + for (int i = 0; i < pattern->length; i++) { + if (!strcmp(pattern->entries[i].object, object)) { + index = i; + break; + } + } + } + FcPatternEntry entry; + memset(&entry, 0, sizeof(entry)); + entry.object = xftStrdup(object); + if (!entry.object) return FcFalse; - entry->type = type; + entry.type = type; switch (type) { case FcTypeString: - entry->value.s = xftStrdup((const char *) value); - if (!entry->value.s) { - free(entry->object); + entry.value.s = xftStrdup((const char *) value); + if (!entry.value.s) { + free(entry.object); return FcFalse; } break; case FcTypeInteger: - entry->value.i = *(const int *) value; + entry.value.i = *(const int *) value; break; case FcTypeDouble: - entry->value.d = *(const double *) value; + entry.value.d = *(const double *) value; break; case FcTypeBool: - entry->value.b = *(const FcBool *) value; + entry.value.b = *(const FcBool *) value; + break; + case FcTypeCharSet: + entry.value.c = copyCharSet((const FcCharSet *) value); + if (!entry.value.c) { + free(entry.object); + return FcFalse; + } break; default: - free(entry->object); + free(entry.object); return FcFalse; } + for (int i = pattern->length; i > index; i--) + pattern->entries[i] = pattern->entries[i - 1]; + pattern->entries[index] = entry; pattern->length++; return FcTrue; } @@ -168,6 +257,8 @@ void FcPatternDestroy(FcPattern *pattern) free(pattern->entries[i].object); if (pattern->entries[i].type == FcTypeString) free(pattern->entries[i].value.s); + if (pattern->entries[i].type == FcTypeCharSet) + FcCharSetDestroy(pattern->entries[i].value.c); } free(pattern->entries); free(pattern); @@ -184,6 +275,8 @@ FcBool FcPatternDel(FcPattern *pattern, const char *object) free(pattern->entries[src].object); if (pattern->entries[src].type == FcTypeString) free(pattern->entries[src].value.s); + if (pattern->entries[src].type == FcTypeCharSet) + FcCharSetDestroy(pattern->entries[src].value.c); found = FcTrue; continue; } @@ -247,6 +340,8 @@ FcBool FcPatternRemove(FcPattern *pattern, const char *object, int n) free(pattern->entries[i].object); if (pattern->entries[i].type == FcTypeString) free(pattern->entries[i].value.s); + if (pattern->entries[i].type == FcTypeCharSet) + FcCharSetDestroy(pattern->entries[i].value.c); for (int j = i; j + 1 < pattern->length; j++) pattern->entries[j] = pattern->entries[j + 1]; pattern->length--; @@ -281,6 +376,9 @@ FcResult FcPatternGet(const FcPattern *pattern, case FcTypeBool: value->u.b = entry->value.b; break; + case FcTypeCharSet: + value->u.c = entry->value.c; + break; default: return FcResultTypeMismatch; } @@ -292,22 +390,41 @@ FcBool FcPatternAddString(FcPattern *pattern, const FcChar8 *s) { return addPatternEntry(pattern, object, FcTypeString, - s ? s : (FcChar8 *) ""); + s ? s : (FcChar8 *) "", FcTrue); } FcBool FcPatternAddInteger(FcPattern *pattern, const char *object, int i) { - return addPatternEntry(pattern, object, FcTypeInteger, &i); + return addPatternEntry(pattern, object, FcTypeInteger, &i, FcTrue); } FcBool FcPatternAddDouble(FcPattern *pattern, const char *object, double d) { - return addPatternEntry(pattern, object, FcTypeDouble, &d); + return addPatternEntry(pattern, object, FcTypeDouble, &d, FcTrue); } FcBool FcPatternAddBool(FcPattern *pattern, const char *object, FcBool b) { - return addPatternEntry(pattern, object, FcTypeBool, &b); + return addPatternEntry(pattern, object, FcTypeBool, &b, FcTrue); +} + +static FcBool addMatrixPatternEntry(FcPattern *pattern, + const char *object, + const FcMatrix *matrix, + FcBool append) +{ + if (!pattern || !object || !matrix) + return FcFalse; + + char buf[sizeof(FcMatrix) * 2 + 1]; + static const char hex[] = "0123456789abcdef"; + const unsigned char *src = (const unsigned char *) matrix; + for (size_t i = 0; i < sizeof(FcMatrix); i++) { + buf[i * 2] = hex[(src[i] >> 4) & 0x0f]; + buf[i * 2 + 1] = hex[src[i] & 0x0f]; + } + buf[sizeof(FcMatrix) * 2] = '\0'; + return addPatternEntry(pattern, object, FcTypeString, buf, append); } FcBool FcPatternAddMatrix(FcPattern *pattern, @@ -321,18 +438,34 @@ FcBool FcPatternAddMatrix(FcPattern *pattern, * cleanly through FcPatternGet. Reject NULL matrices loudly so a miswired * caller does not stash an uninitialized scratch entry. */ - if (!pattern || !object || !matrix) - return FcFalse; + return addMatrixPatternEntry(pattern, object, matrix, FcTrue); +} - char buf[sizeof(FcMatrix) * 2 + 1]; - static const char hex[] = "0123456789abcdef"; - const unsigned char *src = (const unsigned char *) matrix; - for (size_t i = 0; i < sizeof(FcMatrix); i++) { - buf[i * 2] = hex[(src[i] >> 4) & 0x0f]; - buf[i * 2 + 1] = hex[src[i] & 0x0f]; +FcBool FcPatternAdd(FcPattern *pattern, + const char *object, + FcValue value, + FcBool append) +{ + switch (value.type) { + case FcTypeString: + return addPatternEntry(pattern, object, FcTypeString, + value.u.s ? value.u.s : (FcChar8 *) "", append); + case FcTypeInteger: + return addPatternEntry(pattern, object, FcTypeInteger, &value.u.i, + append); + case FcTypeDouble: + return addPatternEntry(pattern, object, FcTypeDouble, &value.u.d, + append); + case FcTypeBool: + return addPatternEntry(pattern, object, FcTypeBool, &value.u.b, append); + case FcTypeMatrix: + return addMatrixPatternEntry(pattern, object, value.u.m, append); + case FcTypeCharSet: + return addPatternEntry(pattern, object, FcTypeCharSet, value.u.c, + append); + default: + return FcFalse; } - buf[sizeof(FcMatrix) * 2] = '\0'; - return addPatternEntry(pattern, object, FcTypeString, buf); } /* Deep copy a pattern. Each entry's heap-owned string is duplicated so @@ -364,6 +497,10 @@ FcPattern *FcPatternDuplicate(const FcPattern *src) case FcTypeBool: ok = FcPatternAddBool(dst, e->object, e->value.b); break; + case FcTypeCharSet: + ok = addPatternEntry(dst, e->object, FcTypeCharSet, e->value.c, + FcTrue); + break; default: ok = FcFalse; break; @@ -376,6 +513,55 @@ FcPattern *FcPatternDuplicate(const FcPattern *src) return dst; } +FcCharSet *FcCharSetCreate(void) +{ + return calloc(1, sizeof(FcCharSet)); +} + +FcBool FcCharSetHasChar(const FcCharSet *charset, FcChar32 ucs4) +{ + if (!charset) + return FcFalse; + if (charset->universal) + return FcTrue; + for (int i = 0; i < charset->length; i++) { + if (charset->chars[i] == ucs4) + return FcTrue; + } + return FcFalse; +} + +FcBool FcCharSetAddChar(FcCharSet *charset, FcChar32 ucs4) +{ + if (!charset) + return FcFalse; + if (FcCharSetHasChar(charset, ucs4)) + return FcTrue; + if (charset->length == charset->capacity) { + if (charset->capacity > INT_MAX / 2) + return FcFalse; + int capacity = charset->capacity ? charset->capacity * 2 : 8; + if ((size_t) capacity > SIZE_MAX / sizeof(FcChar32)) + return FcFalse; + FcChar32 *chars = + realloc(charset->chars, sizeof(FcChar32) * (size_t) capacity); + if (!chars) + return FcFalse; + charset->chars = chars; + charset->capacity = capacity; + } + charset->chars[charset->length++] = ucs4; + return FcTrue; +} + +void FcCharSetDestroy(FcCharSet *charset) +{ + if (!charset) + return; + free(charset->chars); + free(charset); +} + FcResult FcPatternGetString(const FcPattern *pattern, const char *object, int n, @@ -482,6 +668,115 @@ void FcDefaultSubstitute(FcPattern *pattern) return; if (FcPatternGetInteger(pattern, FC_SIZE, 0, NULL) == FcResultNoMatch) FcPatternAddInteger(pattern, FC_SIZE, 12); + if (FcPatternGetString(pattern, FC_STYLE, 0, NULL) == FcResultNoMatch) + FcPatternAddString(pattern, FC_STYLE, (const FcChar8 *) "Regular"); +} + +FcPattern *FcFontMatch(FcConfig *config, FcPattern *pattern, FcResult *result) +{ + (void) config; + if (!pattern) { + if (result) + *result = FcResultNoMatch; + return NULL; + } + FcPattern *match = FcPatternDuplicate(pattern); + if (!match) { + if (result) + *result = FcResultOutOfMemory; + return NULL; + } + FcDefaultSubstitute(match); + if (FcPatternGetString(match, FC_FULLNAME, 0, NULL) == FcResultNoMatch) { + FcChar8 *family = NULL; + if (FcPatternGetString(match, FC_FAMILY, 0, &family) != FcResultMatch) + family = (FcChar8 *) "Sans"; + FcPatternAddString(match, FC_FULLNAME, family); + } + if (result) + *result = FcResultMatch; + return match; +} + +FcObjectSet *FcObjectSetCreate(void) +{ + return calloc(1, sizeof(FcObjectSet)); +} + +FcBool FcObjectSetAdd(FcObjectSet *os, const char *object) +{ + if (!os || !object) + return FcFalse; + if (os->length == os->capacity) { + if (os->capacity > INT_MAX / 2) + return FcFalse; + int capacity = os->capacity ? os->capacity * 2 : 8; + if ((size_t) capacity > SIZE_MAX / sizeof(char *)) + return FcFalse; + char **objects = + realloc(os->objects, sizeof(char *) * (size_t) capacity); + if (!objects) + return FcFalse; + os->objects = objects; + os->capacity = capacity; + } + os->objects[os->length] = xftStrdup(object); + if (!os->objects[os->length]) + return FcFalse; + os->length++; + return FcTrue; +} + +void FcObjectSetDestroy(FcObjectSet *os) +{ + if (!os) + return; + for (int i = 0; i < os->length; i++) + free(os->objects[i]); + free(os->objects); + free(os); +} + +FcFontSet *FcFontList(FcConfig *config, FcPattern *pattern, FcObjectSet *os) +{ + (void) config; + (void) os; + int spacing = FC_PROPORTIONAL; + Bool monoOnly = pattern && + FcPatternGetInteger(pattern, FC_SPACING, 0, &spacing) == + FcResultMatch && + spacing >= FC_MONO; + FcFontSet *set = calloc(1, sizeof(FcFontSet)); + if (!set) + return NULL; + set->sfont = monoOnly ? 1 : 3; + set->fonts = calloc((size_t) set->sfont, sizeof(FcPattern *)); + if (!set->fonts) { + free(set); + return NULL; + } + set->fonts[set->nfont++] = makeFontPattern("Monospace", "Regular", FcTrue); + if (!monoOnly) { + set->fonts[set->nfont++] = makeFontPattern("Sans", "Regular", FcFalse); + set->fonts[set->nfont++] = makeFontPattern("Serif", "Regular", FcFalse); + } + for (int i = 0; i < set->nfont; i++) { + if (!set->fonts[i]) { + FcFontSetDestroy(set); + return NULL; + } + } + return set; +} + +void FcFontSetDestroy(FcFontSet *set) +{ + if (!set) + return; + for (int i = 0; i < set->nfont; i++) + FcPatternDestroy(set->fonts[i]); + free(set->fonts); + free(set); } static int patternSize(FcPattern *pattern) @@ -500,6 +795,15 @@ static int patternSize(FcPattern *pattern) return 12; } +static const FcCharSet *patternCharSet(FcPattern *pattern) +{ + FcValue value; + if (FcPatternGet(pattern, FC_CHARSET, 0, &value) != FcResultMatch || + value.type != FcTypeCharSet) + return NULL; + return value.u.c; +} + static const char *patternFile(FcPattern *pattern) { FcChar8 *file = NULL; @@ -516,9 +820,51 @@ static const char *patternFamily(FcPattern *pattern) return NULL; } +static FcBool ttfProvidesCodepoint(TTF_Font *font, FcChar32 codepoint) +{ + if (!font) + return FcFalse; + return xc_TTF_GlyphIsProvidedUcs4(font, codepoint) ? FcTrue : FcFalse; +} + +static FcBool ttfProvidesCharSet(TTF_Font *font, const FcCharSet *charset) +{ + if (!charset || charset->universal || charset->length <= 0) + return FcTrue; + for (int i = 0; i < charset->length; i++) { + if (!ttfProvidesCodepoint(font, charset->chars[i])) + return FcFalse; + } + return FcTrue; +} + +static TTF_Font *openFallbackForCharSet(const char *family, + int size, + const FcCharSet *charset) +{ + if (!charset || charset->universal || charset->length <= 0) + return compatFontOpenFamilyFallback(family, size); + for (int i = 0; i < charset->length; i++) { + TTF_Font *font = compatFontOpenFamilyFallbackForChar(family, size, + charset->chars[i]); + if (ttfProvidesCharSet(font, charset)) + return font; + if (font) + TTF_CloseFont(font); + } + /* No single host font covers the whole requested charset (for example a + * Latin + CJK mix on a box without a CJK font). Real Xft never refuses + * the pattern here; it returns a base font and resolves the rest per + * glyph at draw time. Mirror that and hand back the best-effort family + * fallback instead of NULL so XftFontOpenPattern stays non-NULL. + */ + return compatFontOpenFamilyFallback(family, size); +} + static TTF_Font *openFontFromPattern(FcPattern *pattern) { int size = patternSize(pattern); + const FcCharSet *requestedCharset = patternCharSet(pattern); /* FC_FILE wins because the caller named an exact file. Anything else routes * through the shared font-family fallback chain in src/font.c so xft and * the core XLoadQueryFont path agree on which TTF backs "helvetica" / @@ -527,10 +873,49 @@ static TTF_Font *openFontFromPattern(FcPattern *pattern) const char *file = patternFile(pattern); if (file) { TTF_Font *font = TTF_OpenFont(file, size); - if (font) + if (font && ttfProvidesCharSet(font, requestedCharset)) return font; + if (font) + TTF_CloseFont(font); + } + return openFallbackForCharSet(patternFamily(pattern), size, + requestedCharset); +} + +static FcCharSet *charSetForTtf(TTF_Font *ttf, const FcCharSet *requested) +{ + FcCharSet *charset = FcCharSetCreate(); + if (!charset) + return NULL; + if (requested && !requested->universal) { + for (int i = 0; i < requested->length; i++) { + if (!ttfProvidesCodepoint(ttf, requested->chars[i])) + continue; + if (!FcCharSetAddChar(charset, requested->chars[i])) { + FcCharSetDestroy(charset); + return NULL; + } + } + return charset; + } + + for (FcChar32 c = 0x20; c <= 0x2ff; c++) { + if (!ttfProvidesCodepoint(ttf, c)) + continue; + if (!FcCharSetAddChar(charset, c)) { + FcCharSetDestroy(charset); + return NULL; + } + } + for (FcChar32 c = 0x370; c <= 0x3ff; c++) { + if (!ttfProvidesCodepoint(ttf, c)) + continue; + if (!FcCharSetAddChar(charset, c)) { + FcCharSetDestroy(charset); + return NULL; + } } - return compatFontOpenFamilyFallback(patternFamily(pattern), size); + return charset; } Bool XftInit(const char *config) @@ -608,6 +993,17 @@ XftFont *XftFontOpenPattern(Display *dpy, FcPattern *pattern) font->public.max_advance_width = 0; TTF_GlyphMetrics(ttf, 'W', NULL, NULL, NULL, NULL, &font->public.max_advance_width); + FcValue charsetValue; + const FcCharSet *requestedCharset = NULL; + if (FcPatternGet(pattern, FC_CHARSET, 0, &charsetValue) == FcResultMatch && + charsetValue.type == FcTypeCharSet) + requestedCharset = charsetValue.u.c; + font->public.charset = charSetForTtf(ttf, requestedCharset); + if (!font->public.charset) { + TTF_CloseFont(ttf); + free(font); + return NULL; + } /* Take a protective reference for clients that destroy the pattern after * this call. XftFontClose releases this extra reference as well as the * ownership reference transferred to XftFontOpenPattern. @@ -677,6 +1073,7 @@ void XftFontClose(Display *dpy, XftFont *font) font->pattern->refcount > 1) FcPatternDestroy(font->pattern); FcPatternDestroy(font->pattern); + FcCharSetDestroy(font->charset); free(compat); } @@ -697,6 +1094,8 @@ XftDraw *XftDrawCreate(Display *dpy, void XftDrawDestroy(XftDraw *draw) { + if (draw) + free(draw->clipRects); free(draw); } @@ -706,6 +1105,50 @@ void XftDrawChange(XftDraw *draw, Drawable drawable) draw->drawable = drawable; } +static int clampToInt(long long value) +{ + if (value < INT_MIN) + return INT_MIN; + if (value > INT_MAX) + return INT_MAX; + return (int) value; +} + +Bool XftDrawSetClipRectangles(XftDraw *draw, + int x_origin, + int y_origin, + const XRectangle *rects, + int n) +{ + if (!draw || n < 0) + return False; + if (n == 0) { + free(draw->clipRects); + draw->clipRects = NULL; + draw->clipRectCount = 0; + return True; + } + if (!rects || (size_t) n > SIZE_MAX / sizeof(XftClipRect)) + return False; + /* Build the replacement before touching the live clip so a failed + * allocation or invalid input leaves the existing clip intact instead of + * silently dropping it (the API has no way to report a partial failure). + */ + XftClipRect *newRects = malloc(sizeof(XftClipRect) * (size_t) n); + if (!newRects) + return False; + for (int i = 0; i < n; i++) { + newRects[i].x = clampToInt((long long) rects[i].x + x_origin); + newRects[i].y = clampToInt((long long) rects[i].y + y_origin); + newRects[i].width = rects[i].width; + newRects[i].height = rects[i].height; + } + free(draw->clipRects); + draw->clipRects = newRects; + draw->clipRectCount = n; + return True; +} + Display *XftDrawDisplay(XftDraw *draw) { return draw ? draw->display : NULL; @@ -856,6 +1299,8 @@ static char *utf16BytesToUtf8(const FcChar8 *string, FcEndian endian, int len) if (!string || len < 0) return NULL; int chars = len / 2; + if ((size_t) chars > SIZE_MAX / sizeof(FcChar16)) + return NULL; FcChar16 *tmp = malloc(sizeof(FcChar16) * (size_t) chars); if (!tmp) return NULL; @@ -881,6 +1326,22 @@ static SDL_Color sdlColorFromXft(const XftColor *color) return sdl; } +static Bool xftClipContains(const XftDraw *draw, int x, int y) +{ + if (!draw || draw->clipRectCount == 0) + return True; + for (int i = 0; i < draw->clipRectCount; i++) { + const XftClipRect *rect = &draw->clipRects[i]; + long long left = rect->x; + long long top = rect->y; + long long right = left + rect->width; + long long bottom = top + rect->height; + if (x >= left && x < right && y >= top && y < bottom) + return True; + } + return False; +} + static unsigned long blendPixel(unsigned long dst, XcPixelFormat format, Uint32 srcPixel, @@ -987,6 +1448,8 @@ static void drawUtf8String(XftDraw *draw, } for (int yy = 0; yy < rectH; yy++) { for (int xx = 0; xx < rectW; xx++) { + if (!xftClipContains(draw, destX + xx, destY + yy)) + continue; Uint32 *row = (Uint32 *) ((char *) glyphs->pixels + (yy + srcY) * glyphs->pitch); unsigned long dst = XGetPixel(image, xx, yy); @@ -1195,17 +1658,17 @@ FcBool XftCharExists(Display *dpy, XftFont *font, FcChar32 ucs4) (void) dpy; if (!font) return FcFalse; + /* The synthesized charset only seeds a few default ranges, so it is not a + * reliable authority on what the rasterizer can draw. A universal charset + * still short-circuits to FcTrue; otherwise defer to TTF_GlyphIsProvided, + * which reflects the actual font. + */ + if (font->charset && font->charset->universal) + return FcTrue; CompatXftFont *compat = (CompatXftFont *) font; if (!compat->ttf) return FcFalse; - /* SDL_ttf only ships a 16-bit glyph probe in the version pinned by this - * repo, so any codepoint beyond the BMP gets a best-effort answer. - * Returning FcTrue keeps the AA path active; the actual glyph render uses - * UTF-8 round-trip which the rasterizer can still handle. - */ - if (ucs4 > 0xffff) - return FcTrue; - return TTF_GlyphIsProvided(compat->ttf, (Uint16) ucs4) ? FcTrue : FcFalse; + return xc_TTF_GlyphIsProvidedUcs4(compat->ttf, ucs4) ? FcTrue : FcFalse; } void XftGlyphExtents(Display *dpy, @@ -1251,7 +1714,34 @@ void XftDrawRect(XftDraw *draw, if (!gc) return; XSetForeground(draw->display, gc, color->pixel); - XFillRectangle(draw->display, draw->drawable, gc, x, y, width, height); + if (draw->clipRectCount == 0) { + XFillRectangle(draw->display, draw->drawable, gc, x, y, width, height); + XFreeGC(draw->display, gc); + return; + } + /* Clip the fill against each clip rectangle. Opaque fills tolerate the + * overlap that intersecting regions would produce. Edges are computed in + * long long so caller-controlled dimensions cannot signed-overflow. + */ + long long rectLeft = x; + long long rectTop = y; + long long rectRight = (long long) x + width; + long long rectBottom = (long long) y + height; + for (int i = 0; i < draw->clipRectCount; i++) { + const XftClipRect *clip = &draw->clipRects[i]; + long long left = clip->x > rectLeft ? clip->x : rectLeft; + long long top = clip->y > rectTop ? clip->y : rectTop; + long long right = (long long) clip->x + clip->width; + if (right > rectRight) + right = rectRight; + long long bottom = (long long) clip->y + clip->height; + if (bottom > rectBottom) + bottom = rectBottom; + if (right > left && bottom > top) + XFillRectangle(draw->display, draw->drawable, gc, (int) left, + (int) top, (unsigned int) (right - left), + (unsigned int) (bottom - top)); + } XFreeGC(draw->display, gc); } @@ -1302,6 +1792,11 @@ int XftUtf8ToUcs4(const FcChar8 *src, FcChar32 *dst, int len) return 1 + extra; } +int FcUtf8ToUcs4(const FcChar8 *src, FcChar32 *dst, int len) +{ + return XftUtf8ToUcs4(src, dst, len); +} + /* Walk `string` counting UTF-8 codepoints; record the count in *nchar and the * minimum UCS bytes-per-character needed to represent the widest codepoint in * *wchar. Per fontconfig's FcUtf8Len contract this is 1 for pure ASCII / diff --git a/src/xtest.c b/src/xtest.c index 6773522..40ff539 100644 --- a/src/xtest.c +++ b/src/xtest.c @@ -195,21 +195,69 @@ int XTestFakeButtonEvent(Display *display, return pushFakeEvent(display, &ev); } +/* Map a modifier key's keycode (low byte of the SDL keycode) to its KMOD mask. + * Returns 0 for non-modifier keys. + */ +static Uint16 kmodForModifierKeycode(unsigned int keycode) +{ + switch (keycode & 0xFF) { + case 0xE0: /* SDLK_LCTRL */ + case 0xE4: /* SDLK_RCTRL */ + return KMOD_CTRL; + case 0xE1: /* SDLK_LSHIFT */ + case 0xE5: /* SDLK_RSHIFT */ + return KMOD_SHIFT; + case 0xE2: /* SDLK_LALT */ + case 0xE6: /* SDLK_RALT */ + return KMOD_ALT; + default: + return 0; + } +} + +/* Modifier keys held by prior fake presses. Synthetic XTest key events carry no + * modifier state on their own, so track held modifier keycodes here and stamp + * the running mask onto every fake key event. This lets replays hold Shift and + * type uppercase, matching how a real keyboard reports modifiers. + * + * ponytail: single static for the serial replay-input path, no locking. + */ +static Uint16 fakeHeldMods = 0; + int XTestFakeKeyEvent(Display *display, unsigned int keycode, Bool is_press, unsigned long delay) { honorDelay(delay); + /* Compute the held-modifier set after this key without committing it yet. + * The event itself carries the pre-press mask (a Shift-down event does not + * report Shift; a Shift-up still does), matching how a real keyboard + * reports modifier state. + */ + Uint16 modBit = kmodForModifierKeycode(keycode); + Uint16 nextMods = fakeHeldMods; + if (modBit) { + if (is_press) + nextMods |= modBit; + else + nextMods &= (Uint16) ~modBit; + } Uint32 winId = replayTargetWindowId(); - if (winId == 0) + if (winId == 0) { + /* No delivery target right now, but keep tracking modifier state so a + * later targeted key still sees the correct held set. + */ + fakeHeldMods = nextMods; return 0; + } SDL_Event ev; SDL_zero(ev); ev.type = is_press ? SDL_KEYDOWN : SDL_KEYUP; ev.key.timestamp = XC_NOW_EVENT_TS(); ev.key.windowID = winId; XC_EVENT_SET_KEY_PRESSED(&ev, is_press); + XC_EVENT_SET_KEYMOD(&ev, fakeHeldMods); /* 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 @@ -219,7 +267,10 @@ int XTestFakeKeyEvent(Display *display, */ XC_EVENT_SET_KEYSYM(&ev, (SDL_Keycode) keycode); XC_EVENT_SET_SCANCODE(&ev, SDL_GetScancodeFromKey((SDL_Keycode) keycode)); - return pushFakeEvent(display, &ev); + int rc = pushFakeEvent(display, &ev); + if (rc) + fakeHeldMods = nextMods; + return rc; } /* Stubs: device-extension variants need XInput plumbing libx11-compat doesn't diff --git a/tests/check.c b/tests/check.c index fb7ebd5..889f41a 100644 --- a/tests/check.c +++ b/tests/check.c @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #include "path/compose.h" #include "path/edges.h" #include "path/path.h" +#include "replay-target.h" #include "state-snapshot.h" #include "timeline.h" #include "util.h" @@ -286,6 +288,45 @@ static int test_keyboard(Display *display) CHECK(lookupSym == XK_A, "XkbLookupKeySym did not return XK_A for Shift+a"); CHECK(consumedModifiers == ShiftMask, "XkbLookupKeySym did not consume ShiftMask"); + + /* Shift maps the whole US top row / punctuation, not just letters. */ + KeySym shiftSym = NoSymbol; + CHECK(XkbLookupKeySym(display, XKeysymToKeycode(display, XK_1), ShiftMask, + NULL, &shiftSym) && + shiftSym == XK_exclam, + "Shift+1 did not produce '!'"); + CHECK(XkbLookupKeySym(display, XKeysymToKeycode(display, XK_slash), + ShiftMask, NULL, &shiftSym) && + shiftSym == XK_question, + "Shift+/ did not produce '?'"); + CHECK(XkbLookupKeySym(display, XKeysymToKeycode(display, XK_minus), + ShiftMask, NULL, &shiftSym) && + shiftSym == XK_underscore, + "Shift+- did not produce '_'"); + + /* Reverse direction must agree: a shifted symbol resolves to its base key + * plus ShiftMask, so accelerators bound on the keysym match Shift+key. + */ + CHECK( + XKeysymToKeycode(display, XK_exclam) == XKeysymToKeycode(display, XK_1), + "XKeysymToKeycode(!) did not resolve to the '1' key"); + CHECK(XkbKeysymToModifiers(display, XK_exclam) == ShiftMask, + "XkbKeysymToModifiers(!) did not report ShiftMask"); + CHECK(XKeysymToKeycode(display, XK_question) == + XKeysymToKeycode(display, XK_slash), + "XKeysymToKeycode(?) did not resolve to the '/' key"); + + /* XKeysymToKeycode must not alias a scancode key (XK_Execute is + * SDLK_EXECUTE 0x40000074, whose low byte is the 't' keycode) onto an + * ASCII letter; otherwise Motif binds osfActivate onto 't'. Every keycode + * it returns must round-trip back to the requested keysym. + */ + KeyCode tCode = XKeysymToKeycode(display, XK_t); + KeyCode execCode = XKeysymToKeycode(display, XK_Execute); + CHECK(tCode != 0 && XkbKeycodeToKeysym(display, tCode, 0, 0) == XK_t, + "XKeysymToKeycode(XK_t) does not round-trip"); + CHECK(execCode == 0 || execCode != tCode, + "XKeysymToKeycode(XK_Execute) aliases onto the 't' keycode"); CHECK(XkbKeysymToModifiers(display, XK_A) == ShiftMask, "XkbKeysymToModifiers did not report ShiftMask for XK_A"); CHECK(XkbKeysymToModifiers(display, XK_Control_L) == ControlMask, @@ -504,20 +545,170 @@ static int test_keyboard(Display *display) "XGrabKeyboard did not report GrabSuccess"); CHECK(XUngrabKeyboard(display, CurrentTime) == 1, "XUngrabKeyboard failed"); - /* XmbLookupString decodes ASCII keysyms to UTF-8 with XLookupBoth. */ + /* Lookup APIs decode physical keycodes through modifiers. */ XKeyEvent ev = {0}; ev.type = KeyPress; ev.display = display; - ev.keycode = (KeyCode) (XK_a & 0xFF); + ev.keycode = XKeysymToKeycode(display, XK_a); char buf[8] = {0}; KeySym lookedUp = NoSymbol; Status lookupStatus = 0; - int n = - XmbLookupString(NULL, &ev, buf, sizeof(buf), &lookedUp, &lookupStatus); - CHECK(n == 1 && buf[0] == 'a', "XmbLookupString did not return 'a'"); - CHECK(lookedUp == XK_a, "XmbLookupString returned wrong keysym"); + + int n = XLookupString(&ev, buf, sizeof(buf), &lookedUp, NULL); + CHECK(n == 1 && buf[0] == 'a', "XLookupString did not return 'a'"); + CHECK(lookedUp == XK_a, "XLookupString returned wrong keysym"); + + memset(buf, 0, sizeof(buf)); + lookedUp = NoSymbol; + ev.state = ShiftMask; + n = XLookupString(&ev, buf, sizeof(buf), &lookedUp, NULL); + CHECK(n == 1 && buf[0] == 'A', "XLookupString did not return shifted 'A'"); + CHECK(lookedUp == XK_A, "XLookupString returned wrong shifted keysym"); + + memset(buf, 0, sizeof(buf)); + lookedUp = NoSymbol; + lookupStatus = 0; + n = XmbLookupString(NULL, &ev, buf, sizeof(buf), &lookedUp, &lookupStatus); + CHECK(n == 1 && buf[0] == 'A', + "XmbLookupString did not return shifted 'A'"); + CHECK(lookedUp == XK_A, "XmbLookupString returned wrong shifted keysym"); CHECK(lookupStatus == XLookupBoth, "XmbLookupString status should be XLookupBoth for ASCII"); + + memset(buf, 0, sizeof(buf)); + lookedUp = NoSymbol; + lookupStatus = 0; + n = Xutf8LookupString(NULL, &ev, buf, sizeof(buf), &lookedUp, + &lookupStatus); + CHECK(n == 1 && buf[0] == 'A', + "Xutf8LookupString did not return shifted 'A'"); + CHECK(lookedUp == XK_A, "Xutf8LookupString returned wrong shifted keysym"); + CHECK(lookupStatus == XLookupBoth, + "Xutf8LookupString status should be XLookupBoth for ASCII"); + + struct { + KeySym keysym; + char byte; + const char *name; + } controls[] = { + {XK_Return, '\r', "Return"}, {XK_Tab, '\t', "Tab"}, + {XK_BackSpace, '\b', "BackSpace"}, {XK_Escape, 0x1b, "Escape"}, + {XK_Delete, 0x7f, "Delete"}, + }; + for (size_t i = 0; i < ARRAY_LENGTH(controls); i++) { + ev.keycode = XKeysymToKeycode(display, controls[i].keysym); + ev.state = 0; + memset(buf, 0, sizeof(buf)); + lookedUp = NoSymbol; + n = XLookupString(&ev, buf, sizeof(buf), &lookedUp, NULL); + CHECK(n == 1 && buf[0] == controls[i].byte, + "XLookupString did not return control byte"); + CHECK(lookedUp == controls[i].keysym, + "XLookupString returned wrong control keysym"); + + memset(buf, 0, sizeof(buf)); + lookedUp = NoSymbol; + lookupStatus = 0; + n = Xutf8LookupString(NULL, &ev, buf, sizeof(buf), &lookedUp, + &lookupStatus); + CHECK(n == 1 && buf[0] == controls[i].byte, + "Xutf8LookupString did not return control byte"); + CHECK(lookedUp == controls[i].keysym, + "Xutf8LookupString returned wrong control keysym"); + CHECK(lookupStatus == XLookupBoth, + "Xutf8LookupString status should be XLookupBoth for controls"); + } + return 1; +} + +/* Drain key events until one for keycode and type arrives, decode it, and + * report whether ShiftMask was set and which UTF-8 byte the lookup produced. + * Returns False if no matching event is delivered within the guard budget. + */ +static Bool awaitFakeKeyEvent(Display *display, + int type, + unsigned int keycode, + Bool *shiftOut, + char *charOut) +{ + for (int guard = 0; guard < 400; guard++) { + if (XPending(display) <= 0) { + XSync(display, False); + continue; + } + XEvent ev; + XNextEvent(display, &ev); + if (ev.type != type || ev.xkey.keycode != (keycode & 0xFF)) + continue; + char buf[8] = {0}; + KeySym keysym = NoSymbol; + Status status = 0; + int n = Xutf8LookupString(NULL, &ev.xkey, buf, sizeof(buf), &keysym, + &status); + *shiftOut = (ev.xkey.state & ShiftMask) ? True : False; + *charOut = (n == 1) ? buf[0] : '\0'; + return True; + } + return False; +} + +/* Regression guard for XTestFakeKeyEvent modifier threading: synthetic key + * events carry no modifier state of their own, so a held fake Shift must be + * stamped onto later fake key events. Without it, replays cannot type + * uppercase. Mapping a large top-level window registers it as the XTest replay + * target so the injected events have somewhere to land. + */ +static int test_xtest_modifiers(Display *display) +{ + Window root = DefaultRootWindow(display); + int sw = DisplayWidth(display, DefaultScreen(display)); + int sh = DisplayHeight(display, DefaultScreen(display)); + Window win = XCreateSimpleWindow(display, root, 0, 0, (unsigned) sw, + (unsigned) sh, 0, 0, 0); + CHECK(win != None, "XCreateSimpleWindow failed"); + XSelectInput(display, win, KeyPressMask | KeyReleaseMask); + XMapWindow(display, win); + XSync(display, False); + XSetInputFocus(display, win, RevertToParent, CurrentTime); + CHECK(replayTargetWindowId() != 0, + "mapped window was not registered as the XTest target"); + + KeyCode shiftCode = XKeysymToKeycode(display, XK_Shift_L); + KeyCode hCode = XKeysymToKeycode(display, XK_h); + CHECK(shiftCode != 0 && hCode != 0, + "modifier/letter keycode lookup failed"); + + while (XPending(display) > 0) { + XEvent drain; + XNextEvent(display, &drain); + } + + Bool shifted = False; + char produced = '\0'; + XTestFakeKeyEvent(display, shiftCode, True, 0); + CHECK(awaitFakeKeyEvent(display, KeyPress, shiftCode, &shifted, &produced), + "no KeyPress delivered for fake Shift press"); + CHECK(!shifted, "fake Shift press carried post-press ShiftMask"); + + XTestFakeKeyEvent(display, hCode, True, 0); + CHECK(awaitFakeKeyEvent(display, KeyPress, hCode, &shifted, &produced), + "no KeyPress delivered for fake Shift+h"); + CHECK(shifted, "held fake Shift was not threaded onto the key event"); + CHECK(produced == 'H', "fake Shift+h did not decode to uppercase 'H'"); + + XTestFakeKeyEvent(display, shiftCode, False, 0); + CHECK( + awaitFakeKeyEvent(display, KeyRelease, shiftCode, &shifted, &produced), + "no KeyRelease delivered for fake Shift release"); + CHECK(shifted, "fake Shift release did not carry pre-release ShiftMask"); + + XTestFakeKeyEvent(display, hCode, True, 0); + CHECK(awaitFakeKeyEvent(display, KeyPress, hCode, &shifted, &produced), + "no KeyPress delivered after Shift release"); + CHECK(!shifted, "Shift state persisted after the fake release"); + CHECK(produced == 'h', "post-release key did not decode to lowercase 'h'"); + + XDestroyWindow(display, win); return 1; } @@ -922,6 +1113,18 @@ static int test_colors(Display *display) CHECK(exact.red == 0xa000 && exact.green == 0xb000 && exact.blue == 0xc000, "XParseColor #rgb returned wrong components"); + memset(&exact, 0, sizeof(exact)); + CHECK(XParseColor(display, colormap, "rgb:e5/e5/e5", &exact), + "XParseColor rgb:r/g/b failed"); + CHECK(exact.red == 0xe5e5 && exact.green == 0xe5e5 && + exact.blue == 0xe5e5 && exact.pixel == 0xFFE5E5E5, + "XParseColor rgb:r/g/b returned wrong components"); + memset(&exact, 0, sizeof(exact)); + CHECK(XParseColor(display, colormap, "rgb:f/f/f", &exact), + "XParseColor rgb:f/f/f failed"); + CHECK(exact.red == 0xffff && exact.green == 0xffff && exact.blue == 0xffff, + "XParseColor rgb: single-digit not bit-replicated to full intensity"); + XColor screen; memset(&exact, 0, sizeof(exact)); memset(&screen, 0, sizeof(screen)); @@ -968,6 +1171,8 @@ static int test_colors(Display *display) screen.pixel = 0x87654321; CHECK(!XParseColor(display, colormap, "#12xx56", &exact), "XParseColor accepted invalid hex"); + CHECK(!XParseColor(display, colormap, "rgb:e5/e5", &exact), + "XParseColor accepted invalid rgb spec"); CHECK(!XLookupColor(display, colormap, "not-a-real-color", &exact, &screen), "XLookupColor accepted invalid color"); CHECK(!XAllocNamedColor(display, colormap, "not-a-real-color", &screen, @@ -1056,6 +1261,21 @@ static int pixel_is_between_black_and_white(SDL_Surface *surface, int x, int y) red < 255; } +static int count_rgb_pixels(SDL_Surface *surface, + Uint8 red, + Uint8 green, + Uint8 blue) +{ + int count = 0; + for (int y = 0; y < surface->h; y++) { + for (int x = 0; x < surface->w; x++) { + if (pixel_is_rgb(surface, x, y, red, green, blue)) + count++; + } + } + return count; +} + static int test_pixmaps(Display *display) { int formatCount = 0; @@ -6098,6 +6318,92 @@ static int test_fonts(Display *display) CHECK(sawBlackTextPixel, "XDrawString treated X11 black pixel as transparent"); + /* Text-stamp invalidation regression. The stamp cache only tracks + * window drawables, and only a TTF (non-fixed) font records a stamp, + * so draw with a helvetica GC on a mapped window. An identical redraw + * of a stamped cell is skipped to keep anti-aliased text from + * accumulating; overwriting the cell must drop the stamp so the redraw + * actually repaints. A stale stamp would skip the redraw and leave the + * overwrite showing, which is the missing-text failure mode of the + * cache. + */ + Window stampDrawWindow = + XCreateSimpleWindow(display, root, 0, 0, 96, 32, 0, 0, 0x00FFFFFF); + CHECK(stampDrawWindow != None, + "text stamp draw window creation failed"); + XMapWindow(display, stampDrawWindow); + GC stampDrawGc = XCreateGC(display, stampDrawWindow, 0, NULL); + CHECK(stampDrawGc != NULL, "text stamp draw GC creation failed"); + XFontStruct *stampDrawFont = + XLoadQueryFont(display, "*-helvetica-medium-r-normal--14-*"); + CHECK(stampDrawFont && stampDrawFont->fid != None, + "text stamp draw font load failed"); + CHECK(XSetFont(display, stampDrawGc, stampDrawFont->fid), + "text stamp draw set font failed"); + CHECK(XSetForeground(display, stampDrawGc, 0), + "text stamp draw black setup failed"); + CHECK( + XDrawString(display, stampDrawWindow, stampDrawGc, 2, 18, "MM", 2), + "text stamp draw first draw failed"); + GET_RENDERER(stampDrawWindow, renderer); + textSurface = getRenderSurface(renderer); + CHECK(textSurface, "text stamp draw first readback failed"); + int stampBlackFirst = count_rgb_pixels(textSurface, 0, 0, 0); + SDL_FreeSurface(textSurface); + CHECK(stampBlackFirst > 0, "text stamp first draw produced no black"); + /* Overwrite the stamped cell with the background; this must invalidate + * the stamp recorded by the first draw. + */ + CHECK(XSetForeground(display, stampDrawGc, 0x00FFFFFF), + "text stamp draw white setup failed"); + CHECK( + XFillRectangle(display, stampDrawWindow, stampDrawGc, 0, 0, 96, 32), + "text stamp draw overwrite failed"); + textSurface = getRenderSurface(renderer); + CHECK(textSurface, "text stamp draw overwrite readback failed"); + int stampBlackCleared = count_rgb_pixels(textSurface, 0, 0, 0); + SDL_FreeSurface(textSurface); + CHECK(stampBlackCleared < stampBlackFirst, + "text stamp overwrite did not clear the cell"); + /* Redraw the identical label. If the overwrite failed to drop the + * stamp, the skip leaves the cell cleared and the black never returns. + */ + CHECK(XSetForeground(display, stampDrawGc, 0), + "text stamp draw redraw black setup failed"); + CHECK( + XDrawString(display, stampDrawWindow, stampDrawGc, 2, 18, "MM", 2), + "text stamp draw redraw failed"); + textSurface = getRenderSurface(renderer); + CHECK(textSurface, "text stamp draw redraw readback failed"); + int stampBlackRedraw = count_rgb_pixels(textSurface, 0, 0, 0); + SDL_FreeSurface(textSurface); + CHECK(stampBlackRedraw > stampBlackCleared, + "overwriting a stamped cell left a stale skip that dropped the " + "redraw"); + XFreeFont(display, stampDrawFont); + XFreeGC(display, stampDrawGc); + XDestroyWindow(display, stampDrawWindow); + + Window stampWindow = + XCreateSimpleWindow(display, root, 0, 0, 96, 32, 0, 0, 0x00FFFFFF); + CHECK(stampWindow != None, "text stamp unmap window creation failed"); + XMapWindow(display, stampWindow); + Window stampCover = XCreateSimpleWindow(display, stampWindow, 0, 0, 48, + 32, 0, 0, 0x00FFFFFF); + CHECK(stampCover != None, "text stamp unmap cover creation failed"); + XMapWindow(display, stampCover); + SDL_Rect stampCell = {2, 4, 24, 16}; + textStampRecord(stampWindow, &stampCell, GET_GC(gc)->font, 0, "MM"); + CHECK( + textStampLookup(stampWindow, &stampCell, GET_GC(gc)->font, 0, "MM"), + "text stamp unmap setup did not record a stamp"); + CHECK(XUnmapWindow(display, stampCover), + "text stamp unmap cover unmap failed"); + CHECK(!textStampLookup(stampWindow, &stampCell, GET_GC(gc)->font, 0, + "MM"), + "unmapping an overlapping child left a stale XDrawString stamp"); + XDestroyWindow(display, stampWindow); + Window textWindow = XCreateSimpleWindow(display, root, 0, 0, 96, 32, 0, 0, 0x00FFFFFF); CHECK(textWindow != None, "XDrawText window creation failed"); @@ -7220,6 +7526,96 @@ static int test_icccm_wm_hints(Display *display) return 1; } +/* An active grab is released when its grab window becomes unviewable. Without + * this a Motif menu's pointer grab outlives the unmapped menu shell and + * swallows all later input (the Help-menu freeze). Exercise unmap, destroy, + * the ancestor-unmap path, and reparent-under-an-unmapped-parent. + */ +static int test_grab_release_on_unviewable(Display *display) +{ + Window root = DefaultRootWindow(display); + + /* Pointer grab dropped when the grab window itself unmaps. */ + Window win = XCreateSimpleWindow(display, root, 0, 0, 32, 32, 0, 0, 0); + CHECK(win != None, "grab-release: window creation failed"); + XMapWindow(display, win); + CHECK(XGrabPointer(display, win, False, ButtonPressMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime) == GrabSuccess, + "grab-release: XGrabPointer failed"); + CHECK(getGrabbedPointerWindow() == win, + "grab-release: pointer grab not active on the window"); + CHECK(XUnmapWindow(display, win), "grab-release: unmap failed"); + CHECK(getGrabbedPointerWindow() == None, + "unmapping the grab window left the pointer grab active"); + XDestroyWindow(display, win); + + /* Keyboard grab dropped on unmap as well. */ + win = XCreateSimpleWindow(display, root, 0, 0, 32, 32, 0, 0, 0); + CHECK(win != None, "grab-release: keyboard window creation failed"); + XMapWindow(display, win); + CHECK(XGrabKeyboard(display, win, False, GrabModeAsync, GrabModeAsync, + CurrentTime) == GrabSuccess, + "grab-release: XGrabKeyboard failed"); + CHECK(getGrabbedKeyboardWindow() == win, + "grab-release: keyboard grab not active on the window"); + CHECK(XUnmapWindow(display, win), "grab-release: keyboard unmap failed"); + CHECK(getGrabbedKeyboardWindow() == None, + "unmapping the grab window left the keyboard grab active"); + XDestroyWindow(display, win); + + /* Pointer grab dropped when the grab window is destroyed. */ + win = XCreateSimpleWindow(display, root, 0, 0, 32, 32, 0, 0, 0); + CHECK(win != None, "grab-release: destroy window creation failed"); + XMapWindow(display, win); + CHECK(XGrabPointer(display, win, False, ButtonPressMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime) == GrabSuccess, + "grab-release: XGrabPointer (destroy) failed"); + CHECK(getGrabbedPointerWindow() == win, + "grab-release: pointer grab not active before destroy"); + XDestroyWindow(display, win); + CHECK(getGrabbedPointerWindow() == None, + "destroying the grab window left the pointer grab active"); + + /* Grab on a child dropped when an ancestor unmaps (isParent path). */ + Window parent = XCreateSimpleWindow(display, root, 0, 0, 64, 64, 0, 0, 0); + CHECK(parent != None, "grab-release: ancestor parent creation failed"); + XMapWindow(display, parent); + Window child = XCreateSimpleWindow(display, parent, 0, 0, 32, 32, 0, 0, 0); + CHECK(child != None, "grab-release: ancestor child creation failed"); + XMapWindow(display, child); + CHECK(XGrabPointer(display, child, False, ButtonPressMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime) == GrabSuccess, + "grab-release: XGrabPointer on child failed"); + CHECK(getGrabbedPointerWindow() == child, + "grab-release: child grab not active"); + CHECK(XUnmapWindow(display, parent), "grab-release: ancestor unmap failed"); + CHECK(getGrabbedPointerWindow() == None, + "unmapping an ancestor of the grab window left the grab active"); + XDestroyWindow(display, parent); + + /* Grab dropped when a mapped grab window is reparented under an unmapped + * parent, which makes it unviewable without an XUnmapWindow. + */ + Window hiddenParent = + XCreateSimpleWindow(display, root, 0, 0, 64, 64, 0, 0, 0); + CHECK(hiddenParent != None, "grab-release: hidden parent creation failed"); + win = XCreateSimpleWindow(display, root, 0, 0, 32, 32, 0, 0, 0); + CHECK(win != None, "grab-release: reparent window creation failed"); + XMapWindow(display, win); + CHECK(XGrabPointer(display, win, False, ButtonPressMask, GrabModeAsync, + GrabModeAsync, None, None, CurrentTime) == GrabSuccess, + "grab-release: XGrabPointer (reparent) failed"); + CHECK(getGrabbedPointerWindow() == win, + "grab-release: grab not active before reparent"); + XReparentWindow(display, win, hiddenParent, 0, 0); + CHECK(getGrabbedPointerWindow() == None, + "reparenting the grab window under an unmapped parent left the grab " + "active"); + XDestroyWindow(display, hiddenParent); + + return 1; +} + static int run_test(const char *name, int (*test)(Display *)) { Display *display = XOpenDisplay(NULL); @@ -8033,6 +8429,7 @@ int main(void) run_test("state_snapshot", test_state_snapshot); run_test("atoms", test_atoms); run_test("keyboard", test_keyboard); + run_test("xtest_modifiers", test_xtest_modifiers); run_test("gc", test_gc); run_test("compat_stubs", test_compat_stubs); run_test("colors", test_colors); @@ -8044,6 +8441,7 @@ int main(void) run_test("path_accelerator", test_path_accelerator); run_test("regions", test_regions); run_test("events", test_events); + run_test("grab_release_on_unviewable", test_grab_release_on_unviewable); run_test("windows", test_windows); run_test("fonts", test_fonts); run_test("contexts", test_contexts); diff --git a/tests/symbol-coverage.c b/tests/symbol-coverage.c index 3dd6661..006eab2 100644 --- a/tests/symbol-coverage.c +++ b/tests/symbol-coverage.c @@ -246,9 +246,21 @@ int main(void) REF(FcPatternRemove); REF(FcPatternAddMatrix); REF(FcUcs4ToUtf8); + REF(FcUtf8ToUcs4); REF(FcNameParse); REF(FcConfigSubstitute); REF(FcDefaultSubstitute); + REF(FcFontMatch); + REF(FcFontList); + REF(FcFontSetDestroy); + REF(FcCharSetCreate); + REF(FcCharSetAddChar); + REF(FcCharSetHasChar); + REF(FcCharSetDestroy); + REF(FcObjectSetCreate); + REF(FcObjectSetAdd); + REF(FcObjectSetDestroy); + REF(FcPatternAdd); REF(FcPatternAddString); REF(FcPatternAddInteger); REF(FcPatternAddDouble); @@ -272,6 +284,7 @@ int main(void) REF(XftDrawCreate); REF(XftDrawDestroy); REF(XftDrawChange); + REF(XftDrawSetClipRectangles); REF(XftDrawDisplay); REF(XftDrawDrawable); REF(XftDrawColormap); diff --git a/tests/test-xft-link.c b/tests/test-xft-link.c index 89fd74e..107005c 100644 --- a/tests/test-xft-link.c +++ b/tests/test-xft-link.c @@ -46,6 +46,23 @@ static int has_neutral_dark_pixel(XImage *image) return 0; } +static int has_dark_pixel_in_rect(XImage *image, int x0, int y0, int w, int h) +{ + if (!image) + return 0; + for (int y = y0; y < y0 + h && y < image->height; y++) { + for (int x = x0; x < x0 + w && x < image->width; x++) { + unsigned long pixel = XGetPixel(image, x, y); + unsigned r = (unsigned) ((pixel >> 24) & 0xffu); + unsigned g = (unsigned) ((pixel >> 16) & 0xffu); + unsigned b = (unsigned) ((pixel >> 8) & 0xffu); + if (r < 0x80 && g < 0x80 && b < 0x80) + return 1; + } + } + return 0; +} + /* Drive the XftFontMatch -> caller-destroys-match -> XftFontClose sequence * Motif uses, and verify the match stays independent from later edits to the * source pattern. @@ -148,6 +165,142 @@ static int exercise_font_copy_refcount(Display *display, int screen) return 1; } +static int exercise_pattern_prepend(void) +{ + FcPattern *pattern = FcPatternCreate(); + FcValue value; + FcChar8 *family = NULL; + int result = 0; + + if (!pattern) { + fprintf(stderr, "FcPatternCreate failed\n"); + return 0; + } + value.type = FcTypeString; + value.u.s = (const FcChar8 *) "first"; + if (!FcPatternAdd(pattern, FC_FAMILY, value, FcTrue)) { + fprintf(stderr, "FcPatternAdd append failed\n"); + goto cleanup; + } + value.u.s = (const FcChar8 *) "override"; + if (!FcPatternAdd(pattern, FC_FAMILY, value, FcFalse)) { + fprintf(stderr, "FcPatternAdd prepend failed\n"); + goto cleanup; + } + if (FcPatternGetString(pattern, FC_FAMILY, 0, &family) != FcResultMatch || + strcmp((const char *) family, "override")) { + fprintf(stderr, "FcPatternAdd prepend did not become value 0\n"); + goto cleanup; + } + if (FcPatternGetString(pattern, FC_FAMILY, 1, &family) != FcResultMatch || + strcmp((const char *) family, "first")) { + fprintf(stderr, "FcPatternAdd prepend displaced old value\n"); + goto cleanup; + } + result = 1; + +cleanup: + FcPatternDestroy(pattern); + return result; +} + +/* Detect whether the host can render a CJK glyph at all, by asking the + * charset-aware fallback for a font that covers just 0x4e00. Minimal CI + * images and the bundled font set carry no CJK font, so the mixed-charset + * test below only enforces CJK coverage where it is actually achievable. + */ +static int host_provides_cjk(Display *display) +{ + FcPattern *pattern = FcPatternCreate(); + FcCharSet *charset = FcCharSetCreate(); + XftFont *font = NULL; + int provides = 0; + + if (!pattern || !charset) + goto cleanup; + if (!FcCharSetAddChar(charset, 0x4e00)) + goto cleanup; + FcValue value; + value.type = FcTypeCharSet; + value.u.c = charset; + if (!FcPatternAddString(pattern, FC_FAMILY, (const FcChar8 *) "Sans") || + !FcPatternAddInteger(pattern, FC_SIZE, 12) || + !FcPatternAdd(pattern, FC_CHARSET, value, FcTrue)) + goto cleanup; + font = XftFontOpenPattern(display, pattern); + /* XftFontOpenPattern only takes ownership of the pattern on success; on + * failure the caller still owns it, so clear the local handle only once + * the font opened to avoid leaking the pattern down the cleanup path. + */ + if (font) + pattern = NULL; + provides = font && XftCharExists(display, font, 0x4e00); + +cleanup: + if (font) + XftFontClose(display, font); + if (pattern) + FcPatternDestroy(pattern); + FcCharSetDestroy(charset); + return provides; +} + +static int exercise_mixed_charset(Display *display) +{ + FcPattern *pattern = FcPatternCreate(); + FcCharSet *charset = FcCharSetCreate(); + XftFont *font = NULL; + int result = 0; + + if (!pattern || !charset) { + fprintf(stderr, "mixed charset setup failed\n"); + goto cleanup; + } + if (!FcCharSetAddChar(charset, 'A') || !FcCharSetAddChar(charset, 0x4e00)) { + fprintf(stderr, "mixed charset add failed\n"); + goto cleanup; + } + FcValue value; + value.type = FcTypeCharSet; + value.u.c = charset; + if (!FcPatternAddString(pattern, FC_FAMILY, (const FcChar8 *) "Sans") || + !FcPatternAddInteger(pattern, FC_SIZE, 12) || + !FcPatternAdd(pattern, FC_CHARSET, value, FcTrue)) { + fprintf(stderr, "mixed charset pattern failed\n"); + goto cleanup; + } + font = XftFontOpenPattern(display, pattern); + if (!font) { + fprintf(stderr, "mixed charset font open failed\n"); + goto cleanup; + } + /* Pattern ownership transferred to the font only now that it opened. */ + pattern = NULL; + if (!XftCharExists(display, font, 'A')) { + fprintf(stderr, "mixed charset font missed ASCII glyph\n"); + goto cleanup; + } + /* When the host actually has a CJK font, the charset-aware fallback must + * have selected one that covers the requested Han glyph. Where no CJK + * font exists, XftFontOpenPattern still returns a usable base font and + * the glyph resolves per draw call, so do not fail the link smoke test. + */ + if (host_provides_cjk(display) && !XftCharExists(display, font, 0x4e00)) { + fprintf(stderr, + "mixed charset fallback dropped a renderable CJK glyph\n"); + goto cleanup; + } + result = 1; + +cleanup: + if (font) + XftFontClose(display, font); + if (pattern) + FcPatternDestroy(pattern); + FcCharSetDestroy(charset); + return result; +} + int main(void) { Display *display = XOpenDisplay(NULL); @@ -156,6 +309,14 @@ int main(void) return 1; } int screen = DefaultScreen(display); + if (!exercise_pattern_prepend()) { + XCloseDisplay(display); + return 1; + } + if (!exercise_mixed_charset(display)) { + XCloseDisplay(display); + return 1; + } if (!exercise_match_refcount(display, screen)) { XCloseDisplay(display); return 1; @@ -184,6 +345,13 @@ int main(void) XCloseDisplay(display); return 1; } + if (XftCharExists(display, font, 0x10ffff)) { + fprintf(stderr, "XftCharExists accepted unsupported non-BMP glyph\n"); + XftFontClose(display, font); + XFreePixmap(display, pixmap); + XCloseDisplay(display); + return 1; + } XftFont *varargFont = XftFontOpen(display, screen, XFT_FAMILY, XftTypeString, "Sans", XFT_SIZE, XftTypeDouble, 12.0, XFT_WEIGHT, XftTypeInteger, @@ -235,9 +403,24 @@ int main(void) const FcChar8 text[] = "Hello"; XftDrawStringUtf8(draw, &color, font, 4, 20, text, (int) strlen((const char *) text)); + XRectangle clip = {4, 0, 24, 32}; + if (!XftDrawSetClipRectangles(draw, 0, 0, &clip, 1)) { + fprintf(stderr, "XftDrawSetClipRectangles failed\n"); + XftDrawDestroy(draw); + XftColorFree(display, DefaultVisual(display, screen), + DefaultColormap(display, screen), &color); + XftFontClose(display, varargFont); + XftFontClose(display, font); + XFreePixmap(display, pixmap); + XCloseDisplay(display); + return 1; + } + XftDrawStringUtf8(draw, &color, font, 80, 20, text, + (int) strlen((const char *) text)); XImage *image = XGetImage(display, pixmap, 0, 0, 256, 32, AllPlanes, ZPixmap); - int ok = has_neutral_dark_pixel(image); + int ok = has_neutral_dark_pixel(image) && + !has_dark_pixel_in_rect(image, 80, 0, 80, 32); if (image) XDestroyImage(image); XftDrawDestroy(draw); diff --git a/tests/ui/assertions/xnedit-typing-changed.json b/tests/ui/assertions/xnedit-typing-changed.json new file mode 100644 index 0000000..3b87715 --- /dev/null +++ b/tests/ui/assertions/xnedit-typing-changed.json @@ -0,0 +1,10 @@ +{ + "assertions": [ + { + "type": "changed_region", + "baseline": "initial", + "rect": [0, 50, 120, 40], + "min_changed_ratio": 0.002 + } + ] +} diff --git a/tests/ui/fixtures/xnedit-fixture.txt b/tests/ui/fixtures/xnedit-fixture.txt new file mode 100644 index 0000000..99b697d --- /dev/null +++ b/tests/ui/fixtures/xnedit-fixture.txt @@ -0,0 +1,6 @@ +XNEdit Xft smoke fixture + +ASCII: quick brown text +UTF-8: café naïve jalapeño +Symbols: λ π Ω +CJK: 台灣測試 diff --git a/tests/ui/replays/xnedit-fixture-differential.replay b/tests/ui/replays/xnedit-fixture-differential.replay new file mode 100644 index 0000000..d778f6c --- /dev/null +++ b/tests/ui/replays/xnedit-fixture-differential.replay @@ -0,0 +1,7 @@ +# System-X11 and libx11-compat XNEdit opened-fixture differential capture. +delay 3000 +wait-window "XNEdit|xnedit|xnedit-fixture" 15000 +wait-converge 200 2 50 200 15000 +screenshot fixture +assert-image fixture common-visible.json +assert-exit running diff --git a/tests/ui/replays/xnedit-fixture.replay b/tests/ui/replays/xnedit-fixture.replay new file mode 100644 index 0000000..f682221 --- /dev/null +++ b/tests/ui/replays/xnedit-fixture.replay @@ -0,0 +1,7 @@ +# XNEdit opened-file smoke. The fixture text forces the Xft glyph path. +delay 3000 +wait-window "XNEdit|xnedit|xnedit-fixture" 8000 +wait-converge 200 2 50 200 15000 +screenshot fixture +assert-image fixture common-visible.json +assert-exit running diff --git a/tests/ui/replays/xnedit-startup-differential.replay b/tests/ui/replays/xnedit-startup-differential.replay new file mode 100644 index 0000000..e6286ef --- /dev/null +++ b/tests/ui/replays/xnedit-startup-differential.replay @@ -0,0 +1,7 @@ +# System-X11 and libx11-compat XNEdit startup differential capture. +delay 3000 +wait-window "XNEdit|xnedit|Untitled" 15000 +wait-converge 200 2 50 200 15000 +screenshot startup +assert-image startup common-visible.json +assert-exit running diff --git a/tests/ui/replays/xnedit-startup.replay b/tests/ui/replays/xnedit-startup.replay new file mode 100644 index 0000000..38a7c56 --- /dev/null +++ b/tests/ui/replays/xnedit-startup.replay @@ -0,0 +1,7 @@ +# XNEdit Motif + Xft startup smoke. +delay 3000 +wait-window "XNEdit|xnedit|Untitled" 8000 +wait-converge 200 2 50 200 15000 +screenshot initial +assert-image initial common-visible.json +assert-exit running diff --git a/tests/ui/replays/xnedit-typing.replay b/tests/ui/replays/xnedit-typing.replay new file mode 100644 index 0000000..e158df5 --- /dev/null +++ b/tests/ui/replays/xnedit-typing.replay @@ -0,0 +1,15 @@ +# XNEdit text-entry smoke. Click into the text pane and type one character. +delay 3000 +wait-window "XNEdit|xnedit|Untitled" 8000 +wait-converge 200 2 50 200 15000 +screenshot initial +target-motion 28 82 +button 1 click +delay 100 +key 97 +delay 500 +wait-converge 200 2 50 200 5000 +screenshot typed +assert-image typed common-visible.json +assert-image typed xnedit-typing-changed.json +assert-exit running