mirror of
https://github.com/ZoiteChat/zoitechat.git
synced 2026-10-20 03:47:12 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88bd1da5c4 | ||
|
|
9b65e5be73 | ||
|
|
9547dd3711 | ||
|
|
abf49ce756 | ||
|
|
6bf2f34fca | ||
|
|
2ea74498ed | ||
|
|
7004c679f2 | ||
|
|
c9de3f2ce0 | ||
|
|
f77d4f6d59 | ||
|
|
89b9a32168 | ||
|
|
357b766369 | ||
|
|
45a8ba18a6 | ||
|
|
0e0f8f72b2 | ||
|
|
dc5f8f7631 | ||
|
|
8a03585611 |
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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``:
|
||||
|
||||
|
||||
+1
-1
Submodule flatpak/shared-modules updated: 55a86b3da4...a58e654b91
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
};
|
||||
|
||||
@@ -23,5 +23,6 @@
|
||||
#include "theme/theme-manager.h"
|
||||
|
||||
void setup_apply_real (const ThemeChangedEvent *event);
|
||||
void setup_open (void);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user