Compare commits

..
Author SHA1 Message Date
deepend 88bd1da5c4 track Python 3.14 updates and latest stable Perl 2026-10-19 21:44:29 -06:00
deepend-tildeclub 9b65e5be73 Merge pull request #432 from ZoiteChat/fix-macos-keyboard-commands
macos: native editing shortcuts+resolve keybinding conflicts
2026-10-19 15:34:32 -06:00
deepend 9547dd3711 prioritize editing shortcuts 2026-10-19 15:25:28 -06:00
deepend-tildeclub abf49ce756 Merge pull request #431 from ZoiteChat/dependabot/github_actions/actions/setup-python-7
ci: bump actions/setup-python from 6 to 7
2026-10-19 13:16:45 -06:00
deepend-tildeclub 6bf2f34fca Merge pull request #430 from ZoiteChat/dependabot/github_actions/actions/checkout-7
ci: bump actions/checkout from 5 to 7
2026-10-19 13:16:23 -06:00
deepend-tildeclub 2ea74498ed Merge pull request #429 from ZoiteChat/dependabot/github_actions/actions/upload-artifact-7
ci: bump actions/upload-artifact from 6 to 7
2026-10-19 13:16:05 -06:00
deepend-tildeclub 7004c679f2 Merge pull request #425 from juergbi/jbilleter/keybindings
Fix menu offsets to fix key bindings
2026-10-19 13:15:47 -06:00
deepend-tildeclub c9de3f2ce0 Merge pull request #428 from ZoiteChat/dependabot/submodules/flatpak/shared-modules-a58e654
Bump flatpak/shared-modules from `55a86b3` to `a58e654`
2026-10-19 12:56:58 -06:00
dependabot[bot] f77d4f6d59 ci: bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 18:38:31 +00:00
dependabot[bot] 89b9a32168 ci: bump actions/checkout from 5 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 18:38:27 +00:00
dependabot[bot] 357b766369 ci: bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 18:38:23 +00:00
deepend 45a8ba18a6 expand Dependabot coverage and group updates 2026-10-19 12:36:50 -06:00
dependabot[bot] 0e0f8f72b2 Bump flatpak/shared-modules from 55a86b3 to a58e654
Bumps [flatpak/shared-modules](https://github.com/flathub/shared-modules) from `55a86b3` to `a58e654`.
- [Commits](https://github.com/flathub/shared-modules/compare/55a86b3da4969984f7de8fb02b3370247c9a266d...a58e654b9113feffaee113cc7167ebbbdb406bee)

---
updated-dependencies:
- dependency-name: flatpak/shared-modules
  dependency-version: a58e654b9113feffaee113cc7167ebbbdb406bee
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-19 18:36:21 +00:00
deepend dc5f8f7631 automate shared-modules updates 2026-10-19 12:21:11 -06:00
Jürg Billeter 8a03585611 Fix menu offsets to fix key bindings
Key bindings for `copy-selection`, `search-text` and others weren't
working due to incorrect offsets.
2026-10-16 22:14:35 +02:00
24 changed files with 1463 additions and 41 deletions
+37
View File
@@ -0,0 +1,37 @@
version: 2
updates:
# Flatpak shared modules
- package-ecosystem: "gitsubmodule"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 2
labels:
- "dependencies"
# GitHub Actions dependencies
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 3
labels:
- "dependencies"
commit-message:
prefix: "ci"
cooldown:
default-days: 7
groups:
actions-minor-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
artifact-metadata: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
submodules: true
@@ -312,7 +312,7 @@ jobs:
subject-path: Zoitechat-*-x86_64.AppImage
- name: Upload AppImage artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: zoitechat-appimage
path: Zoitechat-*-x86_64.AppImage
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
options: --privileged
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
submodules: true
@@ -39,7 +39,7 @@ jobs:
- name: Upload Flatpak Bundle
id: upload_flatpak
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: zoitechat.flatpak
path: zoitechat.flatpak
+2 -2
View File
@@ -55,7 +55,7 @@ jobs:
env:
HOMEBREW_NO_AUTO_UPDATE: '1'
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install native dependencies
run: brew bundle --file packaging/macos/Brewfile
- name: Test packaging logic
@@ -112,7 +112,7 @@ jobs:
env:
HOMEBREW_NO_AUTO_UPDATE: '1'
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Validate release settings before building
+21 -10
View File
@@ -26,13 +26,17 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.14.3'
python-version: '3.14'
check-latest: true
architecture: ${{ matrix.arch }}
- name: Test Windows runtime selection
run: python -m unittest discover -s tools/tests -p test_windows_runtimes.py
- name: Install Dependencies
run: |
New-Item -Name "deps" -ItemType "Directory" -Force | Out-Null
@@ -125,20 +129,20 @@ jobs:
Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.0-pre4/WinSparkle-20260315.7z -OutFile deps\WinSparkle.7z
& 7z.exe x deps\WinSparkle.7z -oC:\gtk-build\WinSparkle
Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.0-pre4/perl-5.42.0.1-${{ matrix.arch }}.7z -OutFile deps\perl-${{ matrix.arch }}.7z
& 7z.exe x deps\perl-${{ matrix.arch }}.7z -oC:\gtk-build\perl-5.42.0.1\${{ matrix.platform }}
python tools/prepare-windows-runtimes.py
if ($LASTEXITCODE -ne 0) { throw 'Could not prepare Windows scripting runtimes' }
$pyRoot = $env:pythonLocation
if (-not $pyRoot) { $pyRoot = & python -c "import sys; print(sys.prefix)" }
foreach ($pyDir in @("C:\gtk-build\python-3.14.3", "C:\gtk-build\python-3.14")) {
foreach ($pyDir in @("C:\gtk-build\python-3.14")) {
New-Item -Path $pyDir -ItemType Directory -Force | Out-Null
$target = Join-Path $pyDir "${{ matrix.platform }}"
if (Test-Path $target) { Remove-Item $target -Recurse -Force }
New-Item -Path $pyDir -Name "${{ matrix.platform }}" -ItemType Junction -Value $pyRoot | Out-Null
}
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.8.10'
architecture: ${{ matrix.arch }}
@@ -162,7 +166,7 @@ jobs:
run: |
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"
set "PYTHON_DIR=C:\gtk-build\python-3.14.3\${{ matrix.platform }}"
set "PYTHON_DIR=C:\gtk-build\python-3.14\${{ matrix.platform }}"
if not exist "%PYTHON_DIR%\libs\python314.lib" (
echo Missing %PYTHON_DIR%\libs\python314.lib
dir "%PYTHON_DIR%\libs"
@@ -188,6 +192,13 @@ jobs:
msbuild win32\zoitechat.sln /m /verbosity:minimal /p:Configuration=Release /p:Platform=${{ matrix.platform }}
if errorlevel 1 exit /b 1
for %%P in (hcpython3 hcperl) do (
if not exist "..\zoitechat-build\${{ matrix.platform }}\rel\plugins\%%P.dll" (
echo Required scripting plugin %%P.dll was not built
exit /b 1
)
)
if not exist "..\zoitechat-build\${{ matrix.platform }}\rel\plugins\hcpython38.dll" (
echo hcpython38.dll was not built
exit /b 1
@@ -222,7 +233,7 @@ jobs:
- name: Upload Installer
id: upload_installer
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: Installer ${{ matrix.arch }}
path: ZoiteChat-*.exe
@@ -236,7 +247,7 @@ jobs:
- name: Upload Build Files
id: upload_buildfiles
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: Build Files ${{ matrix.arch }}
path: zoitechat-build
+21 -10
View File
@@ -27,13 +27,17 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.14.3'
python-version: '3.14'
check-latest: true
architecture: ${{ matrix.arch }}
- name: Test Windows runtime selection
run: python -m unittest discover -s tools/tests -p test_windows_runtimes.py
- name: Install Dependencies
shell: pwsh
run: |
@@ -133,20 +137,20 @@ jobs:
Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.0-pre4/WinSparkle-20260315.7z -OutFile deps\WinSparkle.7z
& 7z.exe x deps\WinSparkle.7z -oC:\gtk-build\WinSparkle
Download-WithRetry -Url https://github.com/ZoiteChat/gvsbuild/releases/download/zoitechat-2.18.0-pre4/perl-5.42.0.1-${{ matrix.arch }}.7z -OutFile deps\perl-${{ matrix.arch }}.7z
& 7z.exe x deps\perl-${{ matrix.arch }}.7z -oC:\gtk-build\perl-5.42.0.1\${{ matrix.platform }}
python tools/prepare-windows-runtimes.py
if ($LASTEXITCODE -ne 0) { throw 'Could not prepare Windows scripting runtimes' }
$pyRoot = $env:pythonLocation
if (-not $pyRoot) { $pyRoot = & python -c "import sys; print(sys.prefix)" }
foreach ($pyDir in @("C:\gtk-build\python-3.14.3", "C:\gtk-build\python-3.14")) {
foreach ($pyDir in @("C:\gtk-build\python-3.14")) {
New-Item -Path $pyDir -ItemType Directory -Force | Out-Null
$target = Join-Path $pyDir "${{ matrix.platform }}"
if (Test-Path $target) { Remove-Item $target -Recurse -Force }
New-Item -Path $pyDir -Name "${{ matrix.platform }}" -ItemType Junction -Value $pyRoot | Out-Null
}
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: '3.8.10'
architecture: ${{ matrix.arch }}
@@ -172,7 +176,7 @@ jobs:
run: |
call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat"
set "PYTHON_DIR=C:\gtk-build\python-3.14.3\${{ matrix.platform }}"
set "PYTHON_DIR=C:\gtk-build\python-3.14\${{ matrix.platform }}"
if not exist "%PYTHON_DIR%\libs\python314.lib" (
echo Missing %PYTHON_DIR%\libs\python314.lib
dir "%PYTHON_DIR%\libs"
@@ -198,6 +202,13 @@ jobs:
msbuild win32\zoitechat.sln /m /verbosity:minimal /p:Configuration=Release /p:Platform=${{ matrix.platform }}
if errorlevel 1 exit /b 1
for %%P in (hcpython3 hcperl) do (
if not exist "..\zoitechat-build\${{ matrix.platform }}\rel\plugins\%%P.dll" (
echo Required scripting plugin %%P.dll was not built
exit /b 1
)
)
if not exist "..\zoitechat-build\${{ matrix.platform }}\rel\plugins\hcpython38.dll" (
echo hcpython38.dll was not built
exit /b 1
@@ -399,7 +410,7 @@ jobs:
- name: Upload MSI Installer
id: upload_msi
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: MSI Installer ${{ matrix.arch }}
path: ${{ steps.build_msi.outputs.path }}
@@ -414,7 +425,7 @@ jobs:
- name: Upload MSIX Package
id: upload_msix
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ steps.build_msix.outputs.artifact_name }}
path: ${{ steps.build_msix.outputs.path }}
+4
View File
@@ -5,6 +5,10 @@ html/
*.patch
tags
# Win32 generated files
win32/runtimes.generated.props
win32/installer/runtimes.generated.iss
/tools/__pycache__/
/tools/tests/__pycache__/
plugins/wmpa/wmpa_h.h
plugins/wmpa/wmpa_i.c
src/htm/obj/*
+31
View File
@@ -53,6 +53,37 @@ Python path if your install is elsewhere::
cmd /c mklink /J C:\gtk-build\python-3.14\x64 C:\Python314
C:\gtk-build\python-3.14\x64\python.exe -m pip install --upgrade pip cffi
Windows runtime updates
~~~~~~~~~~~~~~~~~~~~~~~
Both Windows workflows select the latest stable Python **3.14.x** using
``actions/setup-python`` with ``check-latest: true``. Python 3.15 and prereleases
are excluded. The separate Python 3.8.10 build for older Windows is retained.
``tools/prepare-windows-runtimes.py`` selects the highest stable x64 Strawberry
Perl version from its official release feed, verifies the portable ZIP against
the published SHA-256, and checks the extracted interpreter's version and
architecture before building. Development versions (odd Perl minor numbers)
are excluded. The generated MSBuild properties select its headers and DLL;
the generated Inno include selects the matching MSI, Python installer URL and
component descriptions. Build logs record the exact selected versions.
For a manual build, first install the latest Python 3.14 point release (at least
3.14.8), prepare its junction and CFFI as above, then run from the checkout::
C:\gtk-build\python-3.14\x64\python.exe tools\prepare-windows-runtimes.py
The script downloads the complete Strawberry portable distribution under
``C:\gtk-build``. Generated configuration files are ignored by Git. Running
it again refreshes that dependency; builds without generated files retain the
original manually configured Perl bundle.
Updates happen when a new ZoiteChat build runs. An existing installation keeps
using its installed compatible Python or Perl; installing/updating ZoiteChat
does not force an upgrade of an already detected system runtime. MSI/MSIX
continue to package the scripting plugins; the Inno installer offers runtime
downloads when the corresponding runtime DLL is missing.
If you use different locations, set these environment variables before building
instead of editing ``win32\zoitechat.props``:
+46
View File
@@ -49,6 +49,52 @@ sources and build them in a controlled prefix, or maintain pinned Homebrew formu
## 2. Verify portability and behavior
### Keyboard shortcuts
These defaults apply only to macOS; Linux and Windows shortcuts are unchanged.
| Shortcut | Action |
| --- | --- |
| Command+C / X / V | Copy / cut / paste in text fields, including search and preferences |
| Command+A | Select all text in the focused field |
| Command+Left / Right | Move to the beginning / end of the line |
| Option+Left / Right | Move by words |
| Command+Up / Down | Move to the beginning / end of the text |
| Shift with the movement shortcuts above | Extend the selection |
| Option+Delete (Backspace) | Delete back to the beginning of the word |
| Command+Delete (Backspace) | Delete back to the beginning of the line |
| Command+, | Open Preferences |
| Command+M | Minimize the active window |
| Control+Tab | Completion in the opposite direction; Tab / Shift+Tab still complete normally |
| Command+Option+Left / Right | Move the current tab left / right |
| Command+Shift+R | Reset the chat marker; Command+Shift+M still moves to the marker |
The inherited Control+C/X/V clipboard bindings are removed on macOS only.
Command+C/X/V in multiline editors use plain UTF-8 text directly, avoiding
GTK3 Quartz's incomplete rich-text negotiation and pasteboard types without a
preferred MIME name. Text, newlines, selection replacement and editability
checks are preserved; rich clipboard formatting is not imported by this action.
Command+C copies selected input text first; if no input text is selected, it
copies the selected chat text. Command+X cuts editable text, never messages
from chat history. Password protection and read-only fields retain GTK's
existing behavior. Undo/redo is not added: GTK3's entry widget needs a separate
editing-history implementation before Command+Z can work reliably.
Exact old default assignments for Command+Tab, Option+Left/Right and Command+M
are remapped in memory when loading defaults or an existing configuration.
Other custom bindings are preserved. If a replacement key is already assigned,
both existing assignments are left alone; resolve that conflict in **Settings
→ Keyboard Shortcuts**. Saving there persists the updated assignments.
Command+Tab is left to macOS for switching applications. Plugins and custom
input bindings retain priority; the new native menu accelerators are omitted
when the same key is already assigned to an input action.
Before distributing a build, test the shortcuts in message input, search,
preferences, a detached chat window and a multiline editor, including Unicode,
selection replacement, and read-only/password fields. The Meson widget tests
exercise the GTK actions; the screenshot smoke check does not send real keys
through the macOS event loop.
```sh
python3 -m unittest discover -s packaging/macos/tests -v
python3 packaging/macos/bundle.py audit dist/ZoiteChat.app
+3
View File
@@ -23,6 +23,7 @@
#include "fe-gtk.h"
#ifdef __APPLE__
#include "../common/macos.h"
#include "macos-shortcuts.h"
#endif
#ifdef GDK_WINDOWING_WIN32
@@ -503,6 +504,8 @@ fe_args (int argc, char *argv[])
gtk_init (&argc, &argv);
#ifdef __APPLE__
macos_shortcuts_init (setup_open);
if (g_getenv ("ZOITECHAT_MACOS_SHARE"))
{
char *icons = g_build_filename (g_getenv ("ZOITECHAT_MACOS_SHARE"), "icons", NULL);
+63
View File
@@ -55,6 +55,9 @@
#include "maingui.h"
#include "textgui.h"
#include "fkeys.h"
#ifdef __APPLE__
#include "macos-shortcuts.h"
#endif
#define ICON_FKEYS_NEW "document-new"
#define ICON_FKEYS_DELETE "edit-delete"
@@ -570,6 +573,12 @@ key_handle_key_press (GtkWidget *wid, GdkEventKey *evt, session *sess)
list = g_slist_next (list);
}
#ifdef __APPLE__
/* Plugins and user-defined input shortcuts above retain priority. */
if (macos_shortcuts_copy_chat (wid, evt, GTK_XTEXT (sess->gui->xtext)))
return TRUE;
#endif
switch (evt->keyval)
{
case GDK_KEY_space:
@@ -1333,6 +1342,57 @@ key_save_kbs (void)
#define KBSTATE_DT1 3
#define KBSTATE_DT2 4
#ifdef __APPLE__
static void
key_macos_adjust_defaults (GSList *bindings)
{
GSList *item, *other;
struct key_binding *kb, *existing;
guint keyval;
GdkModifierType mod;
for (item = bindings; item; item = item->next)
{
kb = item->data;
keyval = kb->keyval;
mod = kb->mod;
if (!macos_shortcuts_remap_default (&keyval, &mod,
key_actions[kb->action].name,
kb->data1, kb->data2))
continue;
/* Never let a migrated default shadow an existing user shortcut.
* In that case leave the old assignment for the user to resolve. */
for (other = bindings; other; other = other->next)
{
existing = other->data;
if (existing != kb &&
gdk_keyval_to_lower (existing->keyval) == gdk_keyval_to_lower (keyval) &&
existing->mod == mod)
break;
}
if (!other)
{
kb->keyval = keyval;
kb->mod = mod;
}
}
}
gboolean
key_is_bound (guint keyval, GdkModifierType mod)
{
GSList *item;
for (item = keybind_list; item; item = item->next)
{
struct key_binding *kb = item->data;
if (kb->keyval == keyval && kb->mod == mod)
return TRUE;
}
return FALSE;
}
#endif
#define STRIP_WHITESPACE \
while (buf[0] == ' ' || buf[0] == '\t') \
buf++; \
@@ -1519,6 +1579,9 @@ key_load_kbs_from_buffer (char *ibuf, off_t size, GSList **out_list)
}
}
g_free (ibuf);
#ifdef __APPLE__
key_macos_adjust_defaults (list);
#endif
*out_list = list;
return 0;
+3
View File
@@ -36,5 +36,8 @@ int key_action_insert (GtkWidget * wid, GdkEventKey * evt, char *d1, char *d2,
session *sess);
void key_check_replace_on_change (GtkEditable *editable, gpointer data);
gboolean key_get_menu_accel (const char *name, guint *keyval, GdkModifierType *mod);
#ifdef __APPLE__
gboolean key_is_bound (guint keyval, GdkModifierType mod);
#endif
#endif
+391
View File
@@ -0,0 +1,391 @@
/* macOS text editing shortcuts. GPL-2.0-or-later; see COPYING. */
#include "macos-shortcuts.h"
#include "config.h"
#include "../common/zoitechat.h"
#include "xtext.h"
#include <gdk/gdkkeysyms.h>
static void (*preferences_callback) (void);
static void
copy_text (GtkTextView *view)
{
GtkTextBuffer *buffer = gtk_text_view_get_buffer (view);
GtkTextIter start, end;
if (gtk_text_buffer_get_selection_bounds (buffer, &start, &end))
{
gchar *text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
gtk_clipboard_set_text (gtk_widget_get_clipboard (GTK_WIDGET (view),
GDK_SELECTION_CLIPBOARD), text, -1);
g_free (text);
}
}
static void
cut_text (GtkTextView *view)
{
copy_text (view);
gtk_text_buffer_delete_selection (gtk_text_view_get_buffer (view), TRUE,
gtk_text_view_get_editable (view));
gtk_text_view_reset_im_context (view);
gtk_text_view_scroll_mark_onscreen (view,
gtk_text_buffer_get_insert (gtk_text_view_get_buffer (view)));
}
typedef struct
{
GtkTextView *view;
GtkTextBuffer *buffer;
} TextPaste;
static void
text_paste_received (GtkClipboard *clipboard, const gchar *text, gpointer userdata)
{
TextPaste *paste = userdata;
GtkTextView *view = paste->view;
GtkTextBuffer *buffer = paste->buffer;
/* Clipboard replies may arrive after a view was closed or switched to
* another buffer. Retain the request's objects, but never edit a new one. */
if (text && gtk_widget_get_realized (GTK_WIDGET (view)) &&
gtk_text_view_get_buffer (view) == buffer)
{
gboolean editable = gtk_text_view_get_editable (view);
gtk_text_buffer_begin_user_action (buffer);
gtk_text_buffer_delete_selection (buffer, TRUE, editable);
gtk_text_buffer_insert_interactive_at_cursor (buffer, text, -1, editable);
gtk_text_buffer_end_user_action (buffer);
gtk_text_view_reset_im_context (view);
gtk_text_view_scroll_mark_onscreen (view, gtk_text_buffer_get_insert (buffer));
g_signal_emit_by_name (buffer, "paste-done", clipboard);
}
g_object_unref (buffer);
g_object_unref (view);
g_free (paste);
}
static void
paste_text (GtkTextView *view)
{
TextPaste *paste = g_new (TextPaste, 1);
paste->view = g_object_ref (view);
paste->buffer = g_object_ref (gtk_text_view_get_buffer (view));
/* ZoiteChat's editors consume text. GTK3's Quartz rich-text fallback
* enumerates pasteboard UTIs without MIME names (a NULL atom assertion)
* and its rich-text request callback is unimplemented. Request UTF-8
* directly, retaining interactive selection and read-only behavior. */
gtk_clipboard_request_text (gtk_widget_get_clipboard (GTK_WIDGET (view),
GDK_SELECTION_CLIPBOARD), text_paste_received, paste);
}
static void
open_preferences (GtkWindow *window)
{
if (preferences_callback)
preferences_callback ();
}
static void
minimize_window (GtkWindow *window)
{
gtk_window_iconify (window);
}
void
macos_shortcuts_minimize_active (GtkWindow *fallback)
{
GList *windows = gtk_window_list_toplevels ();
GList *item;
GtkWindow *target = fallback;
for (item = windows; item; item = item->next)
{
if (gtk_window_is_active (GTK_WINDOW (item->data)))
{
target = GTK_WINDOW (item->data);
break;
}
}
if (target)
minimize_window (target);
g_list_free (windows);
}
static void
add_movement (GtkBindingSet *bindings, guint keyval, GdkModifierType mod,
GtkMovementStep step, gint count)
{
gtk_binding_entry_add_signal (bindings, keyval, mod, "move-cursor", 3,
GTK_TYPE_MOVEMENT_STEP, step,
G_TYPE_INT, count, G_TYPE_BOOLEAN, FALSE);
gtk_binding_entry_add_signal (bindings, keyval, mod | GDK_SHIFT_MASK,
"move-cursor", 3, GTK_TYPE_MOVEMENT_STEP, step,
G_TYPE_INT, count, G_TYPE_BOOLEAN, TRUE);
}
static void
delete_text_view_backwards (GtkTextView *view, gboolean word)
{
GtkTextBuffer *buffer = gtk_text_view_get_buffer (view);
GtkTextIter start, end;
gboolean editable = gtk_text_view_get_editable (view);
/* GTK3 leaves GTK_DELETE_DISPLAY_LINE_ENDS unimplemented in TextView.
* Preserve selection deletion and per-range editability instead of
* moving the cursor first or deleting protected text unconditionally. */
if (gtk_text_buffer_get_selection_bounds (buffer, &start, &end))
gtk_text_buffer_delete_selection (buffer, TRUE, editable);
else
{
gtk_text_buffer_get_iter_at_mark (buffer, &end, gtk_text_buffer_get_insert (buffer));
start = end;
if (word)
gtk_text_iter_backward_word_start (&start);
else
gtk_text_view_backward_display_line_start (view, &start);
gtk_text_buffer_begin_user_action (buffer);
gtk_text_buffer_delete_interactive (buffer, &start, &end, editable);
gtk_text_buffer_end_user_action (buffer);
}
gtk_text_view_reset_im_context (view);
gtk_text_view_scroll_mark_onscreen (view, gtk_text_buffer_get_insert (buffer));
}
static void
delete_display_line_start (GtkTextView *view)
{
delete_text_view_backwards (view, FALSE);
}
static void
delete_word_start (GtkTextView *view)
{
delete_text_view_backwards (view, TRUE);
}
static void
install_text_bindings (GtkSettings *settings, GParamSpec *property,
GtkCssProvider *provider)
{
GtkWidget *widgets[2];
const char *selectors[] = {"entry", "textview"};
const char *names[] = {"zoitechat-macos-entry", "zoitechat-macos-textview"};
GdkScreen *screen = gdk_screen_get_default ();
GString *css = g_string_new (NULL);
guint i, j;
/* CSS key themes take precedence over class bindings. Put our small
* sets first, retaining the theme's remaining bindings as fallbacks.
* Remove our previous provider before reading a changed key theme. */
g_object_ref (provider);
gtk_style_context_remove_provider_for_screen (screen, GTK_STYLE_PROVIDER (provider));
widgets[0] = gtk_entry_new ();
widgets[1] = gtk_text_view_new ();
for (i = 0; i < G_N_ELEMENTS (widgets); i++)
{
GPtrArray *fallbacks = NULL;
GtkStyleContext *context = gtk_widget_get_style_context (widgets[i]);
g_object_ref_sink (widgets[i]);
gtk_style_context_get (context, gtk_style_context_get_state (context),
"-gtk-key-bindings", &fallbacks, NULL);
g_string_append_printf (css, "%s { -gtk-key-bindings: %s", selectors[i], names[i]);
for (j = 0; fallbacks && j < fallbacks->len; j++)
{
GtkBindingSet *binding = g_ptr_array_index (fallbacks, j);
g_string_append_printf (css, ", %s", binding->set_name);
}
g_string_append (css, "; }\n");
if (fallbacks)
g_ptr_array_unref (fallbacks);
gtk_widget_destroy (widgets[i]);
g_object_unref (widgets[i]);
}
gtk_css_provider_load_from_data (provider, css->str, css->len, NULL);
gtk_style_context_add_provider_for_screen (screen, GTK_STYLE_PROVIDER (provider),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_string_free (css, TRUE);
g_object_unref (provider);
}
void
macos_shortcuts_init (void (*callback) (void))
{
GType types[] = {GTK_TYPE_ENTRY, GTK_TYPE_TEXT_VIEW};
const char *names[] = {"zoitechat-macos-entry", "zoitechat-macos-textview"};
GtkCssProvider *provider;
gpointer window_class;
GtkBindingSet *window_bindings;
static gboolean initialized = FALSE;
guint i;
preferences_callback = callback;
if (initialized)
return;
initialized = TRUE;
/* Use Command for these three actions on macOS, preserving GTK's
* selection, password-field and read-only checks. Entry subclasses
* (including the spell-checking input and search fields) inherit these. */
for (i = 0; i < G_N_ELEMENTS (types); i++)
{
gpointer klass = g_type_class_ref (types[i]);
GtkBindingSet *bindings = gtk_binding_set_new (names[i]);
gtk_binding_entry_skip (bindings, GDK_KEY_c, GDK_CONTROL_MASK);
gtk_binding_entry_skip (bindings, GDK_KEY_x, GDK_CONTROL_MASK);
gtk_binding_entry_skip (bindings, GDK_KEY_v, GDK_CONTROL_MASK);
if (types[i] == GTK_TYPE_TEXT_VIEW)
{
g_signal_new_class_handler ("zoitechat-macos-copy-text", types[i],
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
G_CALLBACK (copy_text), NULL, NULL, NULL,
G_TYPE_NONE, 0);
g_signal_new_class_handler ("zoitechat-macos-cut-text", types[i],
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
G_CALLBACK (cut_text), NULL, NULL, NULL,
G_TYPE_NONE, 0);
g_signal_new_class_handler ("zoitechat-macos-paste-text", types[i],
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
G_CALLBACK (paste_text), NULL, NULL, NULL,
G_TYPE_NONE, 0);
gtk_binding_entry_add_signal (bindings, GDK_KEY_v, GDK_META_MASK,
"zoitechat-macos-paste-text", 0);
gtk_binding_entry_add_signal (bindings, GDK_KEY_c, GDK_META_MASK,
"zoitechat-macos-copy-text", 0);
gtk_binding_entry_add_signal (bindings, GDK_KEY_x, GDK_META_MASK,
"zoitechat-macos-cut-text", 0);
}
else
{
gtk_binding_entry_add_signal (bindings, GDK_KEY_c, GDK_META_MASK,
"copy-clipboard", 0);
gtk_binding_entry_add_signal (bindings, GDK_KEY_x, GDK_META_MASK,
"cut-clipboard", 0);
gtk_binding_entry_add_signal (bindings, GDK_KEY_v, GDK_META_MASK,
"paste-clipboard", 0);
}
/* Two movements select the whole buffer without changing its text. */
gtk_binding_entry_add_signal (bindings, GDK_KEY_a, GDK_META_MASK,
"move-cursor", 3, GTK_TYPE_MOVEMENT_STEP,
GTK_MOVEMENT_BUFFER_ENDS, G_TYPE_INT, -1,
G_TYPE_BOOLEAN, FALSE);
gtk_binding_entry_add_signal (bindings, GDK_KEY_a, GDK_META_MASK,
"move-cursor", 3, GTK_TYPE_MOVEMENT_STEP,
GTK_MOVEMENT_BUFFER_ENDS, G_TYPE_INT, 1,
G_TYPE_BOOLEAN, TRUE);
add_movement (bindings, GDK_KEY_Left, GDK_META_MASK,
GTK_MOVEMENT_DISPLAY_LINE_ENDS, -1);
add_movement (bindings, GDK_KEY_Right, GDK_META_MASK,
GTK_MOVEMENT_DISPLAY_LINE_ENDS, 1);
add_movement (bindings, GDK_KEY_Left, GDK_MOD1_MASK,
GTK_MOVEMENT_WORDS, -1);
add_movement (bindings, GDK_KEY_Right, GDK_MOD1_MASK,
GTK_MOVEMENT_WORDS, 1);
add_movement (bindings, GDK_KEY_Up, GDK_META_MASK,
GTK_MOVEMENT_BUFFER_ENDS, -1);
add_movement (bindings, GDK_KEY_Down, GDK_META_MASK,
GTK_MOVEMENT_BUFFER_ENDS, 1);
if (types[i] == GTK_TYPE_TEXT_VIEW)
{
g_signal_new_class_handler ("zoitechat-macos-delete-word-start", types[i],
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
G_CALLBACK (delete_word_start),
NULL, NULL, NULL, G_TYPE_NONE, 0);
g_signal_new_class_handler ("zoitechat-macos-delete-line-start", types[i],
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
G_CALLBACK (delete_display_line_start),
NULL, NULL, NULL, G_TYPE_NONE, 0);
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_META_MASK,
"zoitechat-macos-delete-line-start", 0);
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_MOD1_MASK,
"zoitechat-macos-delete-word-start", 0);
}
else
{
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_MOD1_MASK,
"delete-from-cursor", 2, GTK_TYPE_DELETE_TYPE,
GTK_DELETE_WORD_ENDS, G_TYPE_INT, -1);
gtk_binding_entry_add_signal (bindings, GDK_KEY_BackSpace, GDK_META_MASK,
"delete-from-cursor", 2, GTK_TYPE_DELETE_TYPE,
GTK_DELETE_DISPLAY_LINE_ENDS, G_TYPE_INT, -1);
}
g_type_class_unref (klass);
}
provider = gtk_css_provider_new ();
install_text_bindings (gtk_settings_get_default (), NULL, provider);
g_signal_connect_object (gtk_settings_get_default (), "notify::gtk-key-theme-name",
G_CALLBACK (install_text_bindings), provider, 0);
g_signal_connect_object (gtk_settings_get_default (), "notify::gtk-theme-name",
G_CALLBACK (install_text_bindings), provider, 0);
g_object_unref (provider);
/* Window-level bindings also work in dialogs without a main menu bar.
* The native menu uses the same actions when Quartz handles the key. */
window_class = g_type_class_ref (GTK_TYPE_WINDOW);
window_bindings = gtk_binding_set_by_class (window_class);
g_signal_new_class_handler ("zoitechat-macos-minimize", GTK_TYPE_WINDOW,
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
G_CALLBACK (minimize_window), NULL, NULL, NULL,
G_TYPE_NONE, 0);
g_signal_new_class_handler ("zoitechat-macos-preferences", GTK_TYPE_WINDOW,
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
G_CALLBACK (open_preferences), NULL, NULL, NULL,
G_TYPE_NONE, 0);
gtk_binding_entry_add_signal (window_bindings, GDK_KEY_m, GDK_META_MASK,
"zoitechat-macos-minimize", 0);
gtk_binding_entry_add_signal (window_bindings, GDK_KEY_comma, GDK_META_MASK,
"zoitechat-macos-preferences", 0);
g_type_class_unref (window_class);
}
gboolean
macos_shortcuts_remap_default (guint *keyval, GdkModifierType *mod,
const char *action, const char *data1,
const char *data2)
{
/* Only exact legacy defaults qualify. Custom actions, parameters and
* alternate shortcuts must survive loading a configuration unchanged. */
if (data2 && *data2)
return FALSE;
if (*keyval == GDK_KEY_Tab && *mod == GDK_META_MASK &&
!g_strcmp0 (action, "Complete nick/command") && !g_strcmp0 (data1, "Up"))
{
*mod = GDK_CONTROL_MASK;
return TRUE;
}
if ((!data1 || !*data1) && *mod == GDK_MOD1_MASK &&
((*keyval == GDK_KEY_Left && !g_strcmp0 (action, "Move front tab left")) ||
(*keyval == GDK_KEY_Right && !g_strcmp0 (action, "Move front tab right"))))
{
*mod |= GDK_META_MASK;
return TRUE;
}
if (*keyval == GDK_KEY_m && *mod == GDK_META_MASK &&
!g_strcmp0 (action, "Menu Shortcut") && !g_strcmp0 (data1, "reset-marker"))
{
*keyval = GDK_KEY_R;
*mod |= GDK_SHIFT_MASK;
return TRUE;
}
return FALSE;
}
gboolean
macos_shortcuts_copy_chat (GtkWidget *entry, GdkEventKey *event, GtkXText *xtext)
{
gint start, end;
GdkModifierType modifiers = event->state &
(GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK |
GDK_META_MASK | GDK_SUPER_MASK | GDK_HYPER_MASK);
/* XText leaves keyboard focus in the message input. Prefer an input
* selection; otherwise Command+C copies the selected chat text. Leave
* Command+Shift+C and all other shortcuts to their existing handlers. */
if (gdk_keyval_to_lower (event->keyval) != GDK_KEY_c || modifiers != GDK_META_MASK ||
gtk_editable_get_selection_bounds (GTK_EDITABLE (entry), &start, &end))
return FALSE;
gtk_xtext_copy_selection (xtext);
return TRUE;
}
+17
View File
@@ -0,0 +1,17 @@
/* macOS text editing shortcuts. GPL-2.0-or-later; see COPYING. */
#ifndef ZOITECHAT_MACOS_SHORTCUTS_H
#define ZOITECHAT_MACOS_SHORTCUTS_H
#include <gtk/gtk.h>
typedef struct _GtkXText GtkXText;
void macos_shortcuts_init (void (*open_preferences) (void));
void macos_shortcuts_minimize_active (GtkWindow *fallback);
gboolean macos_shortcuts_remap_default (guint *keyval, GdkModifierType *mod,
const char *action, const char *data1,
const char *data2);
gboolean macos_shortcuts_copy_chat (GtkWidget *entry, GdkEventKey *event,
GtkXText *xtext);
#endif
+59 -6
View File
@@ -51,6 +51,9 @@
#include "chanlist.h"
#include "editlist.h"
#include "fkeys.h"
#ifdef __APPLE__
#include "macos-shortcuts.h"
#endif
#include "gtkutil.h"
#include "maingui.h"
#include "notifygui.h"
@@ -1322,6 +1325,17 @@ menu_settings (GtkWidget * wid, gpointer none)
setup_open ();
}
#ifdef __APPLE__
static void
menu_macos_minimize (GtkWidget *widget, gpointer userdata)
{
GtkWindow *fallback = NULL;
if (current_sess && current_sess->gui && current_sess->gui->window)
fallback = GTK_WINDOW (current_sess->gui->window);
macos_shortcuts_minimize_active (fallback);
}
#endif
static void
menu_usermenu (void)
{
@@ -2069,7 +2083,7 @@ static struct mymenu mymenu[] = {
{N_("_Copy Selection"), menu_copy_selection, 0, M_MENUITEM, 0, 0, 1},
{N_("C_lear Text"), menu_flushbuffer, 0, M_MENUITEM, 0, 0, 1},
{N_("Save Text" ELLIPSIS), menu_savebuffer, 0, M_MENUITEM, 0, 0, 1},
#define SEARCH_OFFSET (70)
#define SEARCH_OFFSET (71)
{N_("Search"), 0, 0, M_MENUSUB, 0, 0, 1},
{N_("Search Text" ELLIPSIS), menu_search, 0, M_MENUITEM, 0, 0, 1},
{N_("Search Next" ), menu_search_next, 0, M_MENUITEM, 0, 0, 1},
@@ -2106,11 +2120,11 @@ menu_get_key_action_name (int index)
return "fullscreen-toggle";
case AWAY_OFFSET:
return "away-toggle";
case 65:
return "reset-marker";
case 66:
return "move-marker";
return "reset-marker";
case 67:
return "move-marker";
case 68:
return "copy-selection";
case SEARCH_OFFSET + 1:
return "search-text";
@@ -2118,7 +2132,7 @@ menu_get_key_action_name (int index)
return "search-next";
case SEARCH_OFFSET + 3:
return "search-previous";
case 75:
case 77:
return "contents";
}
@@ -2142,6 +2156,20 @@ menu_add_keybinding_accel (GtkWidget *item, GtkAccelGroup *accel_group, const ch
g_object_set_data (G_OBJECT (item), "zc-key-accel-mod", GUINT_TO_POINTER (mod));
}
#ifdef __APPLE__
static void
menu_add_macos_accel (GtkWidget *item, GtkAccelGroup *accel_group, guint keyval)
{
g_object_set_data (G_OBJECT (item), "zc-macos-default-key", GUINT_TO_POINTER (keyval));
if (!accel_group || key_is_bound (keyval, GDK_META_MASK))
return;
gtk_widget_add_accelerator (item, "activate", accel_group, keyval,
GDK_META_MASK, GTK_ACCEL_VISIBLE);
g_object_set_data (G_OBJECT (item), "zc-key-accel-key", GUINT_TO_POINTER (keyval));
g_object_set_data (G_OBJECT (item), "zc-key-accel-mod", GUINT_TO_POINTER (GDK_META_MASK));
}
#endif
static void
menu_refresh_keybinding_accels (GtkWidget *widget, gpointer data)
{
@@ -2164,6 +2192,12 @@ menu_refresh_keybinding_accels (GtkWidget *widget, gpointer data)
}
name = g_object_get_data (G_OBJECT (widget), "zc-key-action");
#ifdef __APPLE__
keyval = GPOINTER_TO_UINT (g_object_get_data (G_OBJECT (widget), "zc-macos-default-key"));
if (keyval)
menu_add_macos_accel (widget, accel_group, keyval);
else
#endif
menu_add_keybinding_accel (widget, accel_group, name);
submenu = gtk_menu_item_get_submenu (GTK_MENU_ITEM (widget));
@@ -2217,7 +2251,12 @@ menu_key_action (const char *name, guint keyval, GdkModifierType state)
menu_close (NULL, NULL);
else if (!strcmp (name, "quit"))
{
if (!prefs.hex_gui_ctrlq_quit && keyval == GDK_KEY_q && (state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK)) == STATE_CTRL)
if (!prefs.hex_gui_ctrlq_quit && keyval == GDK_KEY_q &&
(state & (GDK_SHIFT_MASK | GDK_CONTROL_MASK | GDK_MOD1_MASK
#ifdef __APPLE__
| GDK_META_MASK
#endif
)) == STATE_CTRL)
return FALSE;
menu_quit (NULL, NULL);
}
@@ -2841,6 +2880,16 @@ menu_create_main (void *accel_group, int bar, int away, int toplevel,
#endif
gtk_menu_shell_append (GTK_MENU_SHELL (menu_bar), menu_item);
gtk_widget_show (menu_item);
#ifdef __APPLE__
if (bar && !strcmp (mymenu[i].text, "_Window"))
{
GtkWidget *minimize = gtk_menu_item_new_with_mnemonic (_("_Minimize"));
menu_add_macos_accel (minimize, accel_group, GDK_KEY_m);
g_signal_connect (minimize, "activate", G_CALLBACK (menu_macos_minimize), NULL);
gtk_menu_shell_append (GTK_MENU_SHELL (menu), minimize);
gtk_widget_show (minimize);
}
#endif
break;
case M_MENUPIX:
@@ -2854,6 +2903,10 @@ menu_create_main (void *accel_group, int bar, int away, int toplevel,
case M_MENUITEM:
item = gtk_menu_item_new_with_mnemonic (_(mymenu[i].text));
normalitem:
#ifdef __APPLE__
if (bar && mymenu[i].callback == menu_settings)
menu_add_macos_accel (item, accel_group, GDK_KEY_comma);
#endif
g_object_set_data (G_OBJECT (item), "zc-key-action", (gpointer) menu_get_key_action_name (i));
menu_add_keybinding_accel (item, accel_group, menu_get_key_action_name (i));
if (mymenu[i].key != 0 && !(mymenu[i].id == MENU_ID_QUIT && !prefs.hex_gui_ctrlq_quit))
+2 -1
View File
@@ -109,6 +109,7 @@ if host_machine.system() == 'windows'
elif host_machine.system() == 'darwin'
add_languages('objc', required: true)
zoitechat_gtk_sources += 'macos-shortcuts.c'
zoitechat_gtk_sources += 'notifications/notification-osx.m'
zoitechat_gtk_deps += dependency('appleframeworks', modules: ['Cocoa', 'UserNotifications'])
zoitechat_gtk_objcflags += '-fobjc-arc'
@@ -296,7 +297,7 @@ test('Emoji Font Tests', emoji_font_tests,
)
xtext_tests = executable('xtext_tests',
['tests/test-xtext.c', '../common/hexcolor.c'],
['tests/test-xtext.c', 'macos-shortcuts.c', '../common/hexcolor.c'],
include_directories: [config_h_include],
dependencies: [gtk_dep],
)
+4
View File
@@ -567,7 +567,11 @@ static const setting general_settings[] =
{ST_TOGGLE, N_("Hide join and part messages"), P_OFFINTNL(hex_irc_conf_mode), N_("Hide channel join/part messages by default."), 0, 0},
{ST_TOGGLE, N_("Hide nick change messages"), P_OFFINTNL(hex_irc_hide_nickchange), 0, 0, 0},
{ST_TOGGLE, N_("Hide hostmasks in join and part messages"), P_OFFINTNL(hex_irc_hide_join_part_hostmask), 0, 0, 0},
#ifdef __APPLE__
{ST_TOGGLE, N_("Enable Command+Q to quit"), P_OFFINTNL(hex_gui_ctrlq_quit), 0, 0, 0},
#else
{ST_TOGGLE, N_("Enable Ctrl+Q to quit"), P_OFFINTNL(hex_gui_ctrlq_quit), 0, 0, 0},
#endif
{ST_END, 0, 0, 0, 0, 0}
};
+1
View File
@@ -23,5 +23,6 @@
#include "theme/theme-manager.h"
void setup_apply_real (const ThemeChangedEvent *event);
void setup_open (void);
#endif
+503
View File
@@ -3,6 +3,7 @@
* adding test-only symbols to the widget's public API. Application services
* are stubbed; GTK, Pango, Cairo and the widget itself are exercised normally. */
#include "../xtext.c"
#include "../macos-shortcuts.h"
#include <glib/gstdio.h>
struct zoitechatprefs prefs;
@@ -252,6 +253,470 @@ static void test_selection_and_clipboard (Fixture *f, gconstpointer data)
g_assert_false (gtk_xtext_is_selecting (f->xtext));
}
static void assert_clipboard (GtkWidget *widget, const char *expected)
{
char *text = gtk_clipboard_wait_for_text (
gtk_widget_get_clipboard (widget, GDK_SELECTION_CLIPBOARD));
g_assert_cmpstr (text, ==, expected);
g_free (text);
}
static GtkWidget *shortcut_window (GtkWidget *widget)
{
GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
/* Give mapped editor fixtures a stable allocation before the first draw. */
gtk_window_set_default_size (GTK_WINDOW (window), 640, 240);
gtk_container_set_border_width (GTK_CONTAINER (window), 16);
if (GTK_IS_ENTRY (widget))
{
gtk_widget_set_size_request (widget, 320, 40);
gtk_widget_set_valign (widget, GTK_ALIGN_CENTER);
}
gtk_container_add (GTK_CONTAINER (window), widget);
gtk_widget_show_all (window);
drain_events ();
return window;
}
static void test_macos_entry_shortcuts (gconstpointer data)
{
gboolean search = GPOINTER_TO_INT (data);
GtkWidget *entry = g_object_ref_sink (search ? gtk_search_entry_new () : gtk_entry_new ());
GtkWidget *window;
GtkClipboard *clipboard;
gint64 deadline;
if (search)
{
/* Inheritance and clipboard actions do not require drawing. Quartz
* SearchEntry icon rendering can emit an unrelated fatal warning.
* Keep this variant attached to a screen but unmapped; the ordinary
* Entry variant below still exercises a mapped, editable widget. */
window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
gtk_container_add (GTK_CONTAINER (window), entry);
g_assert_true (gtk_widget_has_screen (entry));
g_assert_false (gtk_widget_get_mapped (entry));
}
else
{
window = shortcut_window (entry);
g_assert_true (gtk_widget_get_mapped (entry));
}
clipboard = gtk_widget_get_clipboard (entry, GDK_SELECTION_CLIPBOARD);
gtk_entry_set_text (GTK_ENTRY (entry), "hello 猫");
gtk_editable_select_region (GTK_EDITABLE (entry), 6, 7);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK));
assert_clipboard (entry, "猫");
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK));
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello ");
assert_clipboard (entry, "猫");
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_META_MASK));
deadline = g_get_monotonic_time () + G_TIME_SPAN_SECOND * 2;
while (strcmp (gtk_entry_get_text (GTK_ENTRY (entry)), "hello 猫") &&
g_get_monotonic_time () < deadline)
{
drain_events ();
g_usleep (1000);
}
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
/* The old Control actions must no longer perform clipboard editing. */
gtk_editable_select_region (GTK_EDITABLE (entry), 0, -1);
gtk_clipboard_set_text (clipboard, "sentinel", -1);
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_CONTROL_MASK));
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_CONTROL_MASK));
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_CONTROL_MASK));
assert_clipboard (entry, "sentinel");
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
/* Password fields are ordinary entries, not icon-bearing search fields. */
if (!search)
{
gtk_entry_set_visibility (GTK_ENTRY (entry), FALSE);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK));
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK));
assert_clipboard (entry, "sentinel");
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
gtk_entry_set_visibility (GTK_ENTRY (entry), TRUE);
}
gtk_editable_set_editable (GTK_EDITABLE (entry), FALSE);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_x, GDK_META_MASK));
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_META_MASK));
drain_events ();
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "hello 猫");
gtk_widget_destroy (window);
g_object_unref (entry);
}
static void unexpected_textview_clipboard_action (GtkTextView *view, gpointer data)
{
guint *count = data;
GSignalInvocationHint *hint = g_signal_get_invocation_hint (view);
(*count)++;
/* Fail on incorrect dispatch, before GTK's Quartz rich-text path runs. */
g_signal_stop_emission (view, hint->signal_id, hint->detail);
}
static void test_macos_textview_shortcuts (void)
{
GtkWidget *view = g_object_ref_sink (gtk_text_view_new ());
GtkWidget *window = shortcut_window (view);
GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));
GtkTextIter start, end;
gint64 deadline;
guint default_actions = 0;
g_signal_connect (view, "copy-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions);
g_signal_connect (view, "cut-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions);
g_signal_connect (view, "paste-clipboard", G_CALLBACK (unexpected_textview_clipboard_action), &default_actions);
gtk_text_buffer_set_text (buffer, "two\nlines", -1);
g_test_message ("Select multiline text");
gtk_text_buffer_get_bounds (buffer, &start, &end);
gtk_text_buffer_select_range (buffer, &start, &end);
g_test_message ("Copy multiline text");
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_c, GDK_META_MASK));
g_assert_cmpuint (default_actions, ==, 0);
assert_clipboard (view, "two\nlines");
g_test_message ("Cut multiline text");
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_META_MASK));
g_assert_cmpuint (default_actions, ==, 0);
g_assert_cmpint (gtk_text_buffer_get_char_count (buffer), ==, 0);
assert_clipboard (view, "two\nlines");
g_test_message ("Paste multiline text");
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK));
g_assert_cmpuint (default_actions, ==, 0);
deadline = g_get_monotonic_time () + G_TIME_SPAN_SECOND * 2;
while (gtk_text_buffer_get_char_count (buffer) == 0 &&
g_get_monotonic_time () < deadline)
{
drain_events ();
g_usleep (1000);
}
g_assert_cmpint (gtk_text_buffer_get_char_count (buffer), ==, 9);
gtk_text_buffer_get_bounds (buffer, &start, &end);
{
char *text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
g_assert_cmpstr (text, ==, "two\nlines");
g_free (text);
}
g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_c, GDK_CONTROL_MASK));
g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_CONTROL_MASK));
g_assert_false (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_CONTROL_MASK));
gtk_widget_destroy (window);
g_object_unref (view);
}
static void test_macos_chat_copy (Fixture *f, gconstpointer data)
{
GtkWidget *entry = g_object_ref_sink (gtk_entry_new ());
GtkWidget *window = shortcut_window (entry);
GdkEventKey event = {0};
append (f, "chat selection 猫");
select_entries (f, f->xtext->buffer->text_first, f->xtext->buffer->text_last);
event.keyval = GDK_KEY_C; /* Caps Lock must not disable chat copying. */
event.state = GDK_META_MASK | GDK_LOCK_MASK | GDK_MOD2_MASK;
g_assert_true (macos_shortcuts_copy_chat (entry, &event, f->xtext));
assert_clipboard (entry, "chat selection 猫");
/* Selected input takes priority over an older selection in scrollback. */
gtk_entry_set_text (GTK_ENTRY (entry), "input");
gtk_editable_select_region (GTK_EDITABLE (entry), 0, -1);
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_c, GDK_META_MASK));
assert_clipboard (entry, "input");
gtk_editable_select_region (GTK_EDITABLE (entry), 0, 0);
event.state = GDK_META_MASK | GDK_SHIFT_MASK;
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
event.state = GDK_CONTROL_MASK;
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
event.state = GDK_META_MASK;
event.keyval = GDK_KEY_x;
g_assert_false (macos_shortcuts_copy_chat (entry, &event, f->xtext));
assert_clipboard (entry, "input");
gtk_widget_destroy (window);
g_object_unref (entry);
}
static void assert_entry_selection (GtkWidget *entry, gint start, gint end)
{
gint actual_start, actual_end;
g_assert_true (gtk_editable_get_selection_bounds (GTK_EDITABLE (entry),
&actual_start, &actual_end));
g_assert_cmpint (actual_start, ==, start);
g_assert_cmpint (actual_end, ==, end);
}
static void test_macos_key_theme_fallback (void)
{
GtkWidget *entry = gtk_entry_new ();
GtkWidget *window = shortcut_window (entry);
gint start, end;
/* A key-theme reload must keep our overrides first and retain the
* Mac theme's Emacs-style Control+A/E navigation for other keys. */
g_object_notify (G_OBJECT (gtk_settings_get_default ()), "gtk-key-theme-name");
g_object_notify (G_OBJECT (gtk_settings_get_default ()), "gtk-theme-name");
gtk_entry_set_text (GTK_ENTRY (entry), "one two");
gtk_editable_set_position (GTK_EDITABLE (entry), -1);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_CONTROL_MASK));
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 0);
g_assert_false (gtk_editable_get_selection_bounds (GTK_EDITABLE (entry), &start, &end));
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_e, GDK_CONTROL_MASK));
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 7);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_META_MASK));
assert_entry_selection (entry, 0, 7);
g_assert_false (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_v, GDK_CONTROL_MASK));
gtk_widget_destroy (window);
}
static void test_macos_entry_editing (void)
{
GtkWidget *entry = gtk_entry_new ();
GtkWidget *window = shortcut_window (entry);
gtk_entry_set_text (GTK_ENTRY (entry), "one two three");
gtk_editable_set_position (GTK_EDITABLE (entry), 5);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_a, GDK_META_MASK));
assert_entry_selection (entry, 0, 13);
gtk_editable_set_position (GTK_EDITABLE (entry), 5);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_META_MASK));
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 0);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_META_MASK));
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 13);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_META_MASK | GDK_SHIFT_MASK));
assert_entry_selection (entry, 0, 13);
gtk_editable_set_position (GTK_EDITABLE (entry), 13);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_MOD1_MASK));
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 8);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Left, GDK_MOD1_MASK | GDK_SHIFT_MASK));
assert_entry_selection (entry, 4, 8);
gtk_editable_set_position (GTK_EDITABLE (entry), 0);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_MOD1_MASK));
g_assert_cmpint (gtk_editable_get_position (GTK_EDITABLE (entry)), ==, 3);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_Right, GDK_MOD1_MASK | GDK_SHIFT_MASK));
assert_entry_selection (entry, 3, 7);
gtk_editable_set_position (GTK_EDITABLE (entry), 13);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_MOD1_MASK));
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "one two ");
gtk_entry_set_text (GTK_ENTRY (entry), "猫 abc def");
gtk_editable_set_position (GTK_EDITABLE (entry), 6);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK));
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "def");
gtk_editable_select_region (GTK_EDITABLE (entry), 0, 2);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK));
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "f");
gtk_editable_set_editable (GTK_EDITABLE (entry), FALSE);
gtk_editable_set_position (GTK_EDITABLE (entry), -1);
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_MOD1_MASK));
g_assert_true (gtk_bindings_activate (G_OBJECT (entry), GDK_KEY_BackSpace, GDK_META_MASK));
g_assert_cmpstr (gtk_entry_get_text (GTK_ENTRY (entry)), ==, "f");
gtk_widget_destroy (window);
}
static void assert_buffer_text (GtkTextBuffer *buffer, const char *expected)
{
GtkTextIter start, end;
char *text;
gtk_text_buffer_get_bounds (buffer, &start, &end);
text = gtk_text_buffer_get_text (buffer, &start, &end, FALSE);
g_assert_cmpstr (text, ==, expected);
g_free (text);
}
static void paste_completed (GtkTextBuffer *buffer, GtkClipboard *clipboard, guint *count)
{
(*count)++;
}
static void wait_for_paste (guint *count, guint expected)
{
gint64 deadline = g_get_monotonic_time () + 2 * G_TIME_SPAN_SECOND;
while (*count < expected && g_get_monotonic_time () < deadline)
{
drain_events ();
g_usleep (1000);
}
g_assert_cmpuint (*count, ==, expected);
}
static void text_with_native_type (GtkClipboard *clipboard, GtkSelectionData *selection,
guint info, gpointer data)
{
if (info == 0)
gtk_selection_data_set_text (selection, data, -1);
}
static void test_macos_multiline_paste (void)
{
GtkWidget *view = gtk_text_view_new ();
GtkWidget *window = shortcut_window (view);
GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));
GtkClipboard *clipboard = gtk_widget_get_clipboard (view, GDK_SELECTION_CLIPBOARD);
GtkTextIter start, end;
guint pasted = 0;
GtkTargetEntry targets[] = {
{(gchar *) "UTF8_STRING", 0, 0},
/* This base UTI has no preferred MIME name on Quartz. A text paste
* must not enumerate/convert it through GTK's rich-text fallback. */
{(gchar *) "public.item", 0, 1}
};
g_signal_connect (buffer, "paste-done", G_CALLBACK (paste_completed), &pasted);
gtk_text_buffer_set_text (buffer, "before after", -1);
gtk_text_buffer_get_iter_at_offset (buffer, &start, 7);
gtk_text_buffer_get_end_iter (buffer, &end);
gtk_text_buffer_select_range (buffer, &start, &end);
g_assert_true (gtk_clipboard_set_with_data (clipboard, targets, G_N_ELEMENTS (targets),
text_with_native_type, NULL, (gpointer) "猫\npasted"));
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK));
wait_for_paste (&pasted, 1);
assert_buffer_text (buffer, "before 猫\npasted");
gtk_text_view_set_editable (GTK_TEXT_VIEW (view), FALSE);
gtk_text_buffer_get_bounds (buffer, &start, &end);
gtk_text_buffer_select_range (buffer, &start, &end);
gtk_clipboard_set_text (clipboard, "blocked", -1);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_v, GDK_META_MASK));
wait_for_paste (&pasted, 2);
assert_buffer_text (buffer, "before 猫\npasted");
g_assert_false (gtk_text_view_get_editable (GTK_TEXT_VIEW (view)));
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_x, GDK_META_MASK));
assert_buffer_text (buffer, "before 猫\npasted");
assert_clipboard (view, "before 猫\npasted");
gtk_clipboard_clear (clipboard);
gtk_widget_destroy (window);
}
static void test_macos_multiline_editing (void)
{
GtkWidget *view = gtk_text_view_new ();
GtkWidget *window = shortcut_window (view);
GtkTextBuffer *buffer = gtk_text_view_get_buffer (GTK_TEXT_VIEW (view));
GtkTextIter start, end;
gtk_text_buffer_set_text (buffer, "first\nsecond", -1);
gtk_text_buffer_get_iter_at_offset (buffer, &start, 9);
gtk_text_buffer_place_cursor (buffer, &start);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Left, GDK_META_MASK));
gtk_text_buffer_get_iter_at_mark (buffer, &start, gtk_text_buffer_get_insert (buffer));
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 6);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Right, GDK_META_MASK | GDK_SHIFT_MASK));
g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end));
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 6);
g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 12);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_a, GDK_META_MASK));
g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end));
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 0);
g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 12);
gtk_text_buffer_get_iter_at_offset (buffer, &start, 9);
gtk_text_buffer_place_cursor (buffer, &start);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
assert_buffer_text (buffer, "first\nond");
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Down, GDK_META_MASK));
gtk_text_buffer_get_iter_at_mark (buffer, &start, gtk_text_buffer_get_insert (buffer));
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 9);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_Up, GDK_META_MASK | GDK_SHIFT_MASK));
g_assert_true (gtk_text_buffer_get_selection_bounds (buffer, &start, &end));
g_assert_cmpint (gtk_text_iter_get_offset (&start), ==, 0);
g_assert_cmpint (gtk_text_iter_get_offset (&end), ==, 9);
/* An existing selection is deleted without removing the rest of its line. */
gtk_text_buffer_get_iter_at_offset (buffer, &start, 7);
gtk_text_buffer_get_iter_at_offset (buffer, &end, 8);
gtk_text_buffer_select_range (buffer, &start, &end);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
assert_buffer_text (buffer, "first\nod");
gtk_text_view_set_editable (GTK_TEXT_VIEW (view), FALSE);
gtk_text_buffer_get_end_iter (buffer, &end);
gtk_text_buffer_place_cursor (buffer, &end);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
assert_buffer_text (buffer, "first\nod");
gtk_text_view_set_editable (GTK_TEXT_VIEW (view), TRUE);
{
GtkTextTag *protected = gtk_text_buffer_create_tag (buffer, "protected", "editable", FALSE, NULL);
gtk_text_buffer_get_iter_at_offset (buffer, &start, 6);
gtk_text_buffer_get_iter_at_offset (buffer, &end, 7);
gtk_text_buffer_apply_tag (buffer, protected, &start, &end);
gtk_text_buffer_get_end_iter (buffer, &end);
gtk_text_buffer_place_cursor (buffer, &end);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_META_MASK));
assert_buffer_text (buffer, "first\no");
}
gtk_text_buffer_set_text (buffer, "one two\nthree four", -1);
gtk_text_buffer_get_end_iter (buffer, &end);
gtk_text_buffer_place_cursor (buffer, &end);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_MOD1_MASK));
assert_buffer_text (buffer, "one two\nthree ");
gtk_text_buffer_get_iter_at_offset (buffer, &start, 4);
gtk_text_buffer_get_iter_at_offset (buffer, &end, 7);
gtk_text_buffer_select_range (buffer, &start, &end);
g_assert_true (gtk_bindings_activate (G_OBJECT (view), GDK_KEY_BackSpace, GDK_MOD1_MASK));
assert_buffer_text (buffer, "one \nthree ");
gtk_widget_destroy (window);
}
static guint preferences_opened;
static void test_open_preferences (void)
{
preferences_opened++;
}
static void count_minimize (GtkWindow *window, guint *count)
{
(*count)++;
/* Check action routing without requiring an X11 window manager. */
g_signal_stop_emission_by_name (window, "zoitechat-macos-minimize");
}
static void test_macos_window_shortcuts (void)
{
GtkWidget *window = gtk_window_new (GTK_WINDOW_TOPLEVEL);
guint minimized = 0;
preferences_opened = 0;
g_signal_connect (window, "zoitechat-macos-minimize", G_CALLBACK (count_minimize), &minimized);
g_assert_true (gtk_bindings_activate (G_OBJECT (window), GDK_KEY_comma, GDK_META_MASK));
g_assert_cmpuint (preferences_opened, ==, 1);
g_assert_true (gtk_bindings_activate (G_OBJECT (window), GDK_KEY_m, GDK_META_MASK));
g_assert_cmpuint (minimized, ==, 1);
gtk_widget_destroy (window);
}
static void test_macos_legacy_defaults (void)
{
const struct {
guint key, new_key;
GdkModifierType mod, new_mod;
const char *action, *data1, *data2;
gboolean remapped;
} cases[] = {
{GDK_KEY_Tab, GDK_KEY_Tab, GDK_META_MASK, GDK_CONTROL_MASK, "Complete nick/command", "Up", NULL, TRUE},
{GDK_KEY_Left, GDK_KEY_Left, GDK_MOD1_MASK, GDK_META_MASK | GDK_MOD1_MASK, "Move front tab left", NULL, NULL, TRUE},
{GDK_KEY_Right, GDK_KEY_Right, GDK_MOD1_MASK, GDK_META_MASK | GDK_MOD1_MASK, "Move front tab right", "", "", TRUE},
{GDK_KEY_m, GDK_KEY_R, GDK_META_MASK, GDK_META_MASK | GDK_SHIFT_MASK, "Menu Shortcut", "reset-marker", NULL, TRUE},
{GDK_KEY_m, GDK_KEY_m, GDK_META_MASK, GDK_META_MASK, "Menu Shortcut", "search-text", NULL, FALSE},
{GDK_KEY_m, GDK_KEY_m, GDK_CONTROL_MASK, GDK_CONTROL_MASK, "Menu Shortcut", "reset-marker", NULL, FALSE},
{GDK_KEY_Left, GDK_KEY_Left, GDK_MOD1_MASK, GDK_MOD1_MASK, "Move front tab left", "custom", NULL, FALSE},
{GDK_KEY_Tab, GDK_KEY_Tab, GDK_META_MASK, GDK_META_MASK, "Complete nick/command", "Up", "custom", FALSE},
};
guint i;
for (i = 0; i < G_N_ELEMENTS (cases); i++)
{
guint key = cases[i].key;
GdkModifierType mod = cases[i].mod;
g_assert_cmpint (macos_shortcuts_remap_default (&key, &mod, cases[i].action,
cases[i].data1, cases[i].data2), ==, cases[i].remapped);
g_assert_cmpuint (key, ==, cases[i].new_key);
g_assert_cmpuint (mod, ==, cases[i].new_mod);
/* Saved migrated shortcuts must be stable on subsequent launches. */
g_assert_false (macos_shortcuts_remap_default (&key, &mod, cases[i].action,
cases[i].data1, cases[i].data2));
}
}
static void test_search (Fixture *f, gconstpointer data)
{
GError *error = NULL;
@@ -567,7 +1032,44 @@ int main (int argc, char **argv)
g_test_message ("GTK display unavailable; run under Xvfb for widget coverage");
return 77;
}
g_object_set (gtk_settings_get_default (), "gtk-enable-animations", FALSE, NULL);
/* Quartz installs this CSS key theme automatically. Exercise its higher
* priority bindings on Linux too, rather than only testing GTK classes. */
g_object_set (gtk_settings_get_default (), "gtk-key-theme-name", "Mac", NULL);
if (!gtk_binding_set_find ("gtk-mac-cmd-c"))
{
/* Some Linux packages omit GTK's Mac theme. Reproduce the upstream
* CSS clipboard bindings so this regression is still tested there. */
GtkCssProvider *keys = gtk_css_provider_new ();
g_test_message ("Using portable Mac key-theme fixture");
gtk_css_provider_load_from_data (keys,
"@binding-set zoitechat-test-mac-clipboard {"
" bind '<meta>c' { 'copy-clipboard' () };"
" bind '<meta>x' { 'cut-clipboard' () };"
" bind '<meta>v' { 'paste-clipboard' () };"
" bind '<ctrl>a' { 'move-cursor' (paragraph-ends, -1, 0) };"
" bind '<ctrl>e' { 'move-cursor' (paragraph-ends, 1, 0) }; }"
"entry, textview { -gtk-key-bindings: zoitechat-test-mac-clipboard; }",
-1, NULL);
gtk_style_context_add_provider_for_screen (gdk_screen_get_default (),
GTK_STYLE_PROVIDER (keys), GTK_STYLE_PROVIDER_PRIORITY_SETTINGS);
g_object_unref (keys);
}
else
g_test_message ("Using GTK's installed Mac key theme");
prefs.hex_gui_mouse_scroll_speed = 10;
/* Exercise the macOS implementation with real GTK widgets on every OS.
* Production only installs these bindings on macOS. */
macos_shortcuts_init (test_open_preferences);
g_test_add_data_func ("/macos-shortcuts/entry", GINT_TO_POINTER (FALSE), test_macos_entry_shortcuts);
g_test_add_data_func ("/macos-shortcuts/search-entry", GINT_TO_POINTER (TRUE), test_macos_entry_shortcuts);
g_test_add_func ("/macos-shortcuts/textview", test_macos_textview_shortcuts);
g_test_add_func ("/macos-shortcuts/key-theme-fallback", test_macos_key_theme_fallback);
g_test_add_func ("/macos-shortcuts/entry-editing", test_macos_entry_editing);
g_test_add_func ("/macos-shortcuts/multiline-editing", test_macos_multiline_editing);
g_test_add_func ("/macos-shortcuts/multiline-paste", test_macos_multiline_paste);
g_test_add_func ("/macos-shortcuts/window", test_macos_window_shortcuts);
g_test_add_func ("/macos-shortcuts/legacy-defaults", test_macos_legacy_defaults);
#define ADD(name, function) g_test_add ("/xtext/" name, Fixture, NULL, fixture_setup, function, fixture_teardown)
ADD ("append/empty-bounded", test_empty_and_bounded_append);
ADD ("append/utf8-limits", test_utf8_limits);
@@ -577,6 +1079,7 @@ int main (int argc, char **argv)
ADD ("layout/wide-metrics", test_wide_metrics);
ADD ("render/formatting-timestamps", test_formatting);
ADD ("selection/clipboard", test_selection_and_clipboard);
ADD ("selection/macos-command-copy", test_macos_chat_copy);
ADD ("search/navigation-regex-clear", test_search);
ADD ("search/follow", test_follow_search);
ADD ("search/unicode-offsets", test_search_unicode_offsets);
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Prepare current Windows scripting dependencies and matching installer metadata."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
from urllib.parse import urlsplit
import xml.etree.ElementTree as ET
import zipfile
RELEASES_URL = "https://strawberryperl.com/releases.json"
RELEASE_ROOT = "https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/"
MSBUILD_NS = "http://schemas.microsoft.com/developer/msbuild/2003"
def select_perl(releases):
"""Compare versions, not publication dates: older branches get newer releases too."""
candidates = []
for release in releases:
version = release.get("version", "")
if not re.fullmatch(r"5\.\d+\.\d+\.\d+", version):
continue
parts = tuple(map(int, version.split(".")))
if parts[1] % 2 or release.get("archname") != "MSWin32-x64-multi-thread":
continue
if not all(kind in release.get("edition", {}) for kind in ("portable", "msi")):
continue
candidates.append((parts, release))
if not candidates:
raise ValueError("No stable x64 Strawberry Perl release with portable ZIP and MSI")
release = max(candidates, key=lambda item: item[0])[1]
for kind, suffix in (("portable", "portable.zip"), ("msi", "msi")):
package = release["edition"][kind]
expected_name = f"strawberry-perl-{release['version']}-64bit-{suffix}"
if kind == "msi":
expected_name = f"strawberry-perl-{release['version']}-64bit.msi"
url = package.get("url", "")
if not url.startswith(RELEASE_ROOT) or urlsplit(url).path.rsplit("/", 1)[-1] != expected_name:
raise ValueError(f"Unexpected Strawberry Perl {kind} URL: {url}")
if not re.fullmatch(r"[0-9a-fA-F]{64}", package.get("sha256", "")):
raise ValueError(f"Missing SHA-256 for Strawberry Perl {kind}")
return release
def download(url, destination):
for attempt in range(4):
try:
request = urllib.request.Request(url, headers={"User-Agent": "ZoiteChat-build"})
with urllib.request.urlopen(request, timeout=120) as response, destination.open("wb") as output:
while chunk := response.read(1024 * 1024):
output.write(chunk)
return
except (OSError, TimeoutError):
if attempt == 3:
raise
time.sleep(2 ** attempt)
def write_metadata(repo, perl_root, release, python_version):
parts = release["version"].split(".")
perl_lib = f"perl{parts[0]}{parts[1]}"
ET.register_namespace("", MSBUILD_NS)
project = ET.Element(f"{{{MSBUILD_NS}}}Project")
group = ET.SubElement(project, "PropertyGroup")
ET.SubElement(group, "PerlPath").text = str(perl_root)
ET.SubElement(group, "PerlLib").text = perl_lib
ET.ElementTree(project).write(repo / "win32/runtimes.generated.props", encoding="utf-8", xml_declaration=True)
# Only numeric versions and validated upstream URLs enter the Inno source.
definitions = {
"PythonRuntimeVersion": python_version,
"PythonRuntimeURL": f"https://www.python.org/ftp/python/{python_version}/python-{python_version}-amd64.exe",
"PerlRuntimeVersion": release["version"],
"PerlRuntimeURL": release["edition"]["msi"]["url"],
"PerlRuntimeDLL": f"{perl_lib}.dll",
}
(repo / "win32/installer/runtimes.generated.iss").write_text(
"// Generated by tools/prepare-windows-runtimes.py\n"
+ "".join(f'#define {key} "{value}"\n' for key, value in definitions.items()),
encoding="utf-8",
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--deps-root", type=Path, default=Path("C:/gtk-build"))
args = parser.parse_args()
if sys.platform != "win32" or sys.version_info[:2] != (3, 14) or sys.version_info[:3] < (3, 14, 8) or sys.version_info.releaselevel != "final":
parser.error("Run with stable x64 Python 3.14.8 or newer within the 3.14 branch")
if sys.maxsize <= 2 ** 32:
parser.error("The Windows build requires x64 Python")
repo = Path(__file__).resolve().parents[1]
# Do not let an interrupted refresh leave metadata from an older dependency.
for relative in ("win32/runtimes.generated.props", "win32/installer/runtimes.generated.iss"):
(repo / relative).unlink(missing_ok=True)
args.deps_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=args.deps_root) as temporary:
temporary = Path(temporary)
metadata = temporary / "releases.json"
download(RELEASES_URL, metadata)
release = select_perl(json.loads(metadata.read_text(encoding="utf-8")))
archive = temporary / "perl.zip"
download(release["edition"]["portable"]["url"], archive)
with archive.open("rb") as source:
digest = hashlib.file_digest(source, "sha256").hexdigest()
if digest.lower() != release["edition"]["portable"]["sha256"].lower():
raise ValueError("Strawberry Perl portable ZIP SHA-256 mismatch")
# Keep the portable distribution intact, including its companion C DLLs.
target = args.deps_root / f"strawberry-perl-{release['version']}" / "x64"
extracted = temporary / "extracted"
with zipfile.ZipFile(archive) as package:
package.extractall(extracted)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
shutil.rmtree(target)
shutil.move(extracted, target)
perl_root = target / "perl"
environment = os.environ.copy()
paths = [str(perl_root / "bin"), str(target / "c/bin")]
environment["PATH"] = os.pathsep.join(paths + [environment.get("PATH", "")])
perl_lib = "perl" + "".join(release["version"].split(".")[:2])
for required in (perl_root / "bin/perl.exe", perl_root / f"bin/{perl_lib}.dll", perl_root / "lib/CORE/perl.h"):
if not required.is_file():
raise FileNotFoundError(f"Strawberry Perl is missing {required}")
actual = subprocess.check_output(
[str(perl_root / "bin/perl.exe"), "-MConfig", "-e", 'print "$Config{version}|$Config{archname}"'],
env=environment, text=True,
).strip()
expected = ".".join(release["version"].split(".")[:3]) + "|MSWin32-x64-multi-thread"
if actual != expected:
raise ValueError(f"Unexpected Perl runtime: {actual}; expected {expected}")
python_version = ".".join(map(str, sys.version_info[:3]))
write_metadata(repo, perl_root, release, python_version)
if os.environ.get("GITHUB_PATH"):
with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as output:
output.write("\n".join(paths) + "\n")
print(f"Windows runtimes: Python {python_version}; Strawberry Perl {release['version']}")
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
"""Regression coverage for stable release selection and build/installer alignment."""
import copy
import importlib.util
from pathlib import Path
import tempfile
import unittest
import xml.etree.ElementTree as ET
spec = importlib.util.spec_from_file_location(
"windows_runtimes", Path(__file__).resolve().parents[1] / "prepare-windows-runtimes.py"
)
runtimes = importlib.util.module_from_spec(spec)
spec.loader.exec_module(runtimes)
def release(version, arch="MSWin32-x64-multi-thread"):
prefix = runtimes.RELEASE_ROOT + "SP_" + version.replace(".", "") + "_64bit/"
stem = f"strawberry-perl-{version}-64bit"
return {
"version": version,
"archname": arch,
"edition": {
"portable": {"url": prefix + stem + "-portable.zip", "sha256": "a" * 64},
"msi": {"url": prefix + stem + ".msi", "sha256": "b" * 64},
},
}
class RuntimeTests(unittest.TestCase):
def test_compares_versions_instead_of_feed_order_or_dates(self):
releases = [release("5.40.5.1"), release("5.42.3.1"), release("5.42.0.1")]
self.assertEqual(runtimes.select_perl(releases)["version"], "5.42.3.1")
def test_numeric_patch_order(self):
self.assertEqual(runtimes.select_perl([release("5.42.9.1"), release("5.42.10.1")])["version"], "5.42.10.1")
def test_filters_development_prerelease_and_wrong_architecture(self):
releases = [release("5.43.1.1"), release("5.44.0.1-RC1"), release("5.44.0.1", "MSWin32-x86-multi-thread"), release("5.42.3.1")]
self.assertEqual(runtimes.select_perl(releases)["version"], "5.42.3.1")
def test_accepts_next_stable_branch(self):
self.assertEqual(runtimes.select_perl([release("5.42.3.1"), release("5.44.0.1")])["version"], "5.44.0.1")
def test_requires_both_build_and_installer_packages(self):
newer = release("5.44.0.1")
del newer["edition"]["msi"]
self.assertEqual(runtimes.select_perl([newer, release("5.42.3.1")])["version"], "5.42.3.1")
def test_fails_if_no_suitable_release_exists(self):
with self.assertRaises(ValueError):
runtimes.select_perl([release("5.43.1.1")])
def test_rejects_bad_hashes_untrusted_hosts_and_wrong_packages(self):
for kind in ("portable", "msi"):
for field, value in (("sha256", ""), ("url", "https://example.org/perl.zip"), ("url", runtimes.RELEASE_ROOT + "SP/strawberry-perl-5.40.5.1-64bit.msi")):
with self.subTest(kind=kind, field=field, value=value):
invalid = copy.deepcopy(release("5.42.3.1"))
invalid["edition"][kind][field] = value
with self.assertRaises(ValueError):
runtimes.select_perl([invalid])
def test_generated_dll_and_installer_follow_selected_release(self):
with tempfile.TemporaryDirectory() as directory:
repo = Path(directory)
(repo / "win32/installer").mkdir(parents=True)
selected = release("5.44.1.1")
perl_root = Path("C:/gtk-build/Perl & tools/perl")
runtimes.write_metadata(repo, perl_root, selected, "3.14.8")
props = ET.parse(repo / "win32/runtimes.generated.props")
ns = {"m": runtimes.MSBUILD_NS}
self.assertEqual(props.find("m:PropertyGroup/m:PerlLib", ns).text, "perl544")
self.assertEqual(props.find("m:PropertyGroup/m:PerlPath", ns).text, str(perl_root))
installer = (repo / "win32/installer/runtimes.generated.iss").read_text()
self.assertIn('#define PerlRuntimeDLL "perl544.dll"', installer)
self.assertIn(selected["edition"]["msi"]["url"], installer)
self.assertIn("/3.14.8/python-3.14.8-amd64.exe", installer)
if __name__ == "__main__":
unittest.main()
+17 -5
View File
@@ -4,6 +4,18 @@
;#define APPARCH "x64"
;#define PROJECTDIR "C:\...\zoitechat\win32\installer\"
; CI generates these from the runtimes actually used to build the plugins.
#if FileExists(PROJECTDIR + "runtimes.generated.iss")
#include PROJECTDIR + "runtimes.generated.iss"
#else
; Manual builds without the preparation script retain the original Perl bundle.
#define PythonRuntimeVersion "3.14.8"
#define PythonRuntimeURL "https://www.python.org/ftp/python/3.14.8/python-3.14.8-amd64.exe"
#define PerlRuntimeVersion "5.42.0.1"
#define PerlRuntimeURL "https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/SP_54201_64bit/strawberry-perl-5.42.0.1-64bit.msi"
#define PerlRuntimeDLL "perl542.dll"
#endif
[Setup]
AppName=ZoiteChat
AppVersion={#APPVER}
@@ -58,8 +70,8 @@ Name: "plugins\sysinfo"; Description: "SysInfo"; Types: custom; Flags: disableno
Name: "plugins\upd"; Description: "Update Checker"; Types: normal custom; Flags: disablenouninstallwarning
Name: "langs"; Description: "Language Interfaces"; Types: custom; Flags: disablenouninstallwarning
Name: "langs\lua"; Description: "Lua (LuaJIT 2.1)"; Types: normal custom; Flags: disablenouninstallwarning
Name: "langs\perl"; Description: "Perl (Strawberry Perl 5.42.0.1)"; Types: custom; Flags: disablenouninstallwarning
Name: "langs\python"; Description: "Python (Python 3.14.3, or 3.8.10 before Windows 10)"; Types: custom; Flags: disablenouninstallwarning
Name: "langs\perl"; Description: "Perl (Strawberry Perl {#PerlRuntimeVersion})"; Types: custom; Flags: disablenouninstallwarning
Name: "langs\python"; Description: "Python (Python {#PythonRuntimeVersion}, or 3.8.10 before Windows 10)"; Types: custom; Flags: disablenouninstallwarning
Name: "themes"; Description: "GTK3 Themes"; Types: normal minimal custom; Flags: disablenouninstallwarning
Name: "themes\windows10"; Description: "Windows 10"; Types: normal minimal custom; Flags: disablenouninstallwarning
Name: "themes\windows10dark"; Description: "Windows 10 Dark"; Types: normal minimal custom; Flags: disablenouninstallwarning
@@ -349,7 +361,7 @@ begin
if NeedsLegacyPython() then
Result := 'https://www.python.org/ftp/python/3.8.10/python-3.8.10-amd64.exe'
else
Result := 'https://www.python.org/ftp/python/3.14.3/python-3.14.3-amd64.exe';
Result := '{#PythonRuntimeURL}';
end;
function PythonDLL(): String;
@@ -387,7 +399,7 @@ begin
begin
REDIST := 'https://aka.ms/vs/17/release/vc_redist.x64.exe';
PERL := 'https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/SP_54201_64bit/strawberry-perl-5.42.0.1-64bit.msi';
PERL := '{#PerlRuntimeURL}';
PY3 := PythonURL();
SPELL := 'https://github.com/hexchat/gvsbuild/releases/download/hexchat-2.16.2/HexChat.Spelling.Dictionaries.r2.exe';
@@ -399,7 +411,7 @@ begin
if not WizardSilent() then
begin
if WizardIsComponentSelected('langs\perl') and not CheckDLL('perl542.dll') then
if WizardIsComponentSelected('langs\perl') and not CheckDLL('{#PerlRuntimeDLL}') then
begin
idpAddFile(PERL, ExpandConstant('{tmp}\perl.msi'))
end;
+3 -2
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildThisFileDirectory)runtimes.generated.props" Condition="Exists('$(MSBuildThisFileDirectory)runtimes.generated.props')" />
<PropertyGroup Label="UserMacros">
<!-- SPECIFY YOUR DEPENDENCY DIRECTORIES HERE -->
@@ -24,8 +25,8 @@
<DepsRoot>$(YourDepsPath)\$(ZoiteChatPlatform)\release</DepsRoot>
<GendefPath>$(YourGendefPath)</GendefPath>
<WinSparklePath>$(YourWinSparklePath)\$(ZoiteChatPlatform)</WinSparklePath>
<PerlPath>$(YourPerlPath)\$(ZoiteChatPlatform)</PerlPath>
<PerlLib>perl542</PerlLib>
<PerlPath Condition="'$(PerlPath)'==''">$(YourPerlPath)\$(ZoiteChatPlatform)</PerlPath>
<PerlLib Condition="'$(PerlLib)'==''">perl542</PerlLib>
<Python3Path>$(YourPython3Path)\$(ZoiteChatPlatform)</Python3Path>
<Python3Lib>python314</Python3Lib>