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`.
+
+
+
+ ```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