48 Commits

Author SHA1 Message Date
qiannian 1a5fa17a39 app: update TopMost comment
TopMost is also supported on Windows.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-27 17:50:54 +08:00
qiannian 5191409708 app: [Windows] support TopMost option
Use HWND_TOPMOST and HWND_NOTOPMOST when applying Config.TopMost on Windows.

Leave SWP_NOZORDER set when TopMost hasn't changed, so other Configure calls don't reorder the window.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-27 10:38:28 +02:00
Jeff Williams 3eab806940 app: gracefully handle Windows clipboard read errors
Signed-off-by: Jeff Williams <info@anvil-editor.net>
2026-06-26 14:25:53 +02:00
qiannian 30dc7ff294 app: [Windows] don't make raised windows topmost
Use HWND_TOP instead of HWND_TOPMOST in ActionRaise so raising a
window no longer pins it on top.

Signed-off-by: qiannian <qianniancn@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-25 18:53:27 +02:00
Elias Naur 9e18cb93fb ap/internal/windows: change type of HWND_TOPMOST to syscall.Handle
syscall.Handle is equivalent to the C type HWND.

Inspired by a patch by qiannian.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-25 11:18:57 +02:00
qiannian e4932e163e widget,io,app: draw IME composition underline
Track IME composition range updates as key.CompositionEvent and route them to
the focused editor. Draw a thin underline under the current composition range
using the editor text material.

Keep the composition range in imeState so it is reset with the rest of the
editor input method state, and normalize it in input.Router before forwarding
CompositionEvent.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-17 12:05:40 -04:00
qiannian a8fe27488f app: [Windows] avoid default IME composition window
Mark WM_IME_STARTCOMPOSITION as handled after positioning the IME
composition and candidate windows.

Passing the message on to DefWindowProc can let Windows create its default
composition window, which shows the first composing character below Gio's
caret.

Fixes: https://todo.sr.ht/~eliasnaur/gio/687
Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-17 09:07:13 +02:00
qiannian 06307313cd io/input: support direct pointer leave events
Allow platform backends to send pointer.Leave directly.
The router delivers it to entered handlers so hover state is cleared normally.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-17 08:05:23 +02:00
qiannian 15335a2b37 app: [Windows] restore double-click restore for custom title bars
Keep custom move areas mapped to HTCAPTION even when the window is
maximized so custom title bars preserve the standard Windows behavior
where double-click restores the window.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-06-16 10:06:47 +02:00
Qian Nian acf5635575 widget/material: add hover state to window decorations
Decorations buttons already use widget.Clickable and draw press ink, but they don't draw a hover/focus background. This makes custom window decorations appear unresponsive when the pointer is over minimize, maximize, or close buttons.

Register each button's system action over its clickable area and draw the same hovered background used by other material buttons. This also lets platforms query the button action from the material decorations hit area.

Signed-off-by: qiannian <qianniancn@gmail.com>
2026-06-14 10:50:56 +02:00
Kevin Yuan caccb608a5 app: [Windows] don't propagate WM_WINDOWPOSCHANGED to DefWindowProc
DefWindowProc handles WM_WINDOWPOSCHANGED by sending WM_SIZE and WM_MOVE
messages, which would lead us to handle resizes twice.

Per MSDN, the WM_SIZE handler is made redundant by handling
WM_WINDOWPOSCHANGED:
https://learn.microsoft.com/en-us/windows/win32/winmsg/wm-windowposchanged

Signed-off-by: Kevin Yuan <farproc@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-05-26 12:50:02 +02:00
Kevin Yuan d52632b475 internal/egl: fix loadEGL caching error on Windows
loadEGL used sync.Once incorrectly: the error returned by
loadDLLs was assigned to a local variable inside loadEGL,
so on the second call Do would not run and the
function would return nil even though the DLLs were never
loaded. This caused a nil pointer dereference when callers
proceeded to use _eglGetDisplay and other uninitialized
function pointers.

Fix by replacing the sync.Once with sync.OnceValue, which
correctly caches the return value of loadDLLs across all
calls.

Signed-off-by: Kevin Yuan <farproc@gmail.com>
2026-05-26 08:07:26 +02:00
Egon Elbre dec57aea1c go.mod: upgrade to github.com/go-text/typesetting@v0.3.4
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-05-18 14:30:25 -04:00
Egon Elbre e2e2c1a046 text: avoid creating two Face instances
This way their cache can be shared.

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-05-18 14:30:17 -04:00
Egon Elbre e8c1e1ba11 font/opentype: fix font.Face creation
typesetting introduced a cache field that needs to be
properly initialized. Use constructor to avoid the issue.

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-05-18 14:30:12 -04:00
Eugene b1cadbdd76 io/input: do not track scroll events as pointers
Scroll events arrived at pointerQueue.Push and went through pointerOf
+ deliverEnterLeaveEvents + deliverEvent like Move/Press/Release. The
side effect: every scroll created or updated a state.pointers entry,
populated p.entered with whatever handlers sat under the wheel
position, and overwrote state.cursor based on hit-test at the scroll
position.

When the platform layer reports scroll with a different PointerID
than mouse-move events for the same physical mouse — which the
Windows backend does (scrollEvent omits PointerID, defaulting to 0,
while pointerUpdate forwards Windows' assigned ID) — the scroll
spawns a phantom state.pointers entry. Subsequent moves go to the
mouse's "real" entry, so the phantom never receives Leave events,
its entered set never empties, and the cleanup at the end of Push
keeps it alive. pointerQueue.Frame then runs hit-test for it every
frame at the user's last scroll position, threading state.cursor
through it after the live pointer's resolution and clobbering it
with whatever's under the scroll position.

The wheel is positional but isn't a pointer. Treating it as one is
the bug. Hit-test inline at the scroll position to find delivery
targets, dispatch via deliverEvent (which already handles filter
matching, scroll axis clamping, and area-local position), and
return without creating or updating a state.pointers entry.

Add a router-level test that fails without the fix: a Move sets the
cursor over a CursorPointer region, a subsequent Scroll over a
CursorText region, and the test asserts the cursor is still
CursorPointer. Pre-fix the scroll's deliverEnterLeaveEvents
overwrites state.cursor with CursorText.

Signed-off-by: Eugene <eugenebosyakov@gmail.com>
2026-05-18 09:05:32 +02:00
Chris Waldon 451b7d3a74 text: drop obsolete comment about NewWindow
Fixes: https://todo.sr.ht/~eliasnaur/gio/681
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2026-05-06 13:29:01 -04:00
Eugene e49c5b02c7 gesture: refresh PointerID on Press and Enter
Click and Hover both stored the first PointerID they observed in
their internal pid field and only updated it when not currently
hovered/entered. Once the gesture became hovered, any later event
under a different PointerID was effectively ignored: Click.Press
fell through 'c.pid != e.PointerID' and was silently dropped, and
Hover could never reset entered when the matching Leave arrived
under a new ID.

The Windows backend enables EnableMouseInPointer
(app/os_windows.go), under which Windows reassigns the same
physical mouse's PointerID across focus changes, window
leave/re-enter, and similar events. Once a widget had been hovered,
every subsequent press on it failed to register, including
widget.Editor's internal clicker that positions the caret on press.
Multi-line editors silently refused to move the caret on click
after the window had received any focus event.

Always take the latest PointerID on Hover.Enter and Click.Press.
The Press/Release handshake still works because Press now records
the press's own PointerID and Release continues to gate on
'c.pid != e.PointerID' so an unrelated pointer's release can't end
the press tracking.

Signed-off-by: Eugene <eugenebosyakov@gmail.com>
2026-04-30 06:19:20 +02:00
Elias Naur dfe4ff0200 app,io/key: go fmt
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-03-17 17:10:59 +01:00
Lucas Rodrigues 65d86895b8 text: render SVG font as black-white outline
Previously, using SVG fonts will cause Gio to render invisible
"characters".

Now, some fonts (like Noto Sans Emoji) will be rendered
based on "Outline". Gio don't support SVG fonts, but now
it will show the outline ("black-and-white") alternative.

Signed-off-by: Lucas Rodrigues <inkeliz@inkeliz.com>
2026-03-16 08:55:01 -04:00
Lucas Rodrigues c3a6e85f5c app: [js] fixes IME
Fix several issues in the IME implementation

- Typing after an editor regains focus will not add text at the begging of the Editor.
- On Safari iOS, selecting a keyboard suggestion no longer duplicates the text.
- Replacing selected text now occurs at the correct position.

This patch change how "input" event is handled, instead of consider all events the
same.

Signed-off-by: Lucas Rodrigues <inkeliz@inkeliz.com>
2026-03-13 08:42:02 +01:00
Lucas Rodrigues 8c2e45b8f8 app,io: [js] change Shortcut key on macOS/iOS
Previously, the Shortcut key was hardcoded as ModCtrl. That
patches tries to identify the OS and change the key.

Fixes: https://todo.sr.ht/~eliasnaur/gio/624
Signed-off-by: Lucas Rodrigues <inkeliz@inkeliz.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-03-13 08:41:45 +01:00
CoyAce 47ab4c97b2 app: [android] remove redundant ConfigEvent in onStop and onSurfaceDestroyed
Config.Focused represents window interaction focus, which should only
change when the window gains or loses user interaction capability.

The focus state is already correctly handled in:
- onResume → focused = true  (window gains interaction focus)
- onPause  → focused = false (window loses interaction focus)

Therefore, sending additional ConfigEvent in onStop and onSurfaceDestroyed
is redundant because:
- onStop is always preceded by onPause
- onSurfaceDestroyed is a low-level surface event unrelated to focus

This cleanup aligns the Android implementation with the correct semantic
that Config.Focused = window interaction focus, not view focus or
surface state.

Signed-off-by: CoyAce <akeycoy@gmail.com>
2026-03-10 13:01:39 +01:00
CoyAce 760369174d app: [iOS] fix focus event for iOS 13.0+ with backward compatibility
UIScene notifications are the correct way to track window focus on
iOS 13.0+, because the Key Window API is scene-level on iOS 13.0+,
but we need to maintain support for iOS 12 and earlier.

Implementation strategy:
- iOS 13.0+: Use UIScene notifications (DidActivate/WillDeactivate)
- iOS 12 and earlier: Keep existing UIWindow notifications as fallback
- Add runtime version checks to select the appropriate API

Key changes in os_ios.m:
- Add @available(iOS 13.0, *) checks
- Register both notification types with conditional logic
- Ensure proper cleanup of observers when moving between windows

See: https://developer.apple.com/documentation/uikit/uiscene?language=objc

Fixes: https://lists.sr.ht/~eliasnaur/gio/%3CCAMAFT9Uyh_JWrkQQt+AmekJWFBqhZPsP_3ZxC1fUNB+=VGGorw@mail.gmail.com%3E
Signed-off-by: CoyAce <akeycoy@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-03-10 10:08:49 +01:00
CoyAce 92fa23b59b app: [android] replace OnFocusChangedListener with onPause/onResume
References: https://lists.sr.ht/~eliasnaur/gio/%3CCAMAFT9Uyh_JWrkQQt+AmekJWFBqhZPsP_3ZxC1fUNB+=VGGorw@mail.gmail.com%3E
Signed-off-by: CoyAce <akeycoy@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-03-09 16:40:12 +01:00
inkeliz f98baf7f76 app: [js] add IME support
Signed-off-by: inkeliz <inkeliz@inkeliz.com>
2026-03-09 09:07:09 +01:00
Elias Naur a6da4083de test: go fmt
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-03-09 08:54:01 +01:00
Thomas Banks bbb54d5f54 app: enable creation of top most windows
Floating windows are rendered above all other non-floating windows.

Apple Documentation: https://developer.apple.com/documentation/appkit/nswindow/level-swift.struct

Signed-off-by: Thomas Banks <thomas@tombanks.me>
2026-02-19 08:04:13 +01:00
Egon Elbre 8b96643490 widget/material: add LayoutWidgets for adding scrollable widgets
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-02-19 08:01:58 +01:00
Egon Elbre 4ed9695d57 layout: add List.Gap for spacing out items
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-02-19 08:01:55 +01:00
Egon Elbre 9b38545fc2 layout: add Flex.Gap for spacing out items
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-02-19 08:01:50 +01:00
Elias Naur 0d08eaa55c app: remove go1.18 go:build conditional
Our go.mod says 1.24, so a go1.18 conditional is redundant.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-02-18 08:36:57 +01:00
Egon Elbre 9ab8095d1a text: fix length check
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-02-18 08:36:57 +01:00
Egon Elbre 3d6cafa94d all: run go fix
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2026-02-18 08:36:57 +01:00
CoyAce 9966e922f9 widget: fix text selection and selection area rendering issues
1. When selecting multiple lines of text, the rendered selection area does not include the last character of the first line.
2. When selecting a specific line (other than the last line) in multiline text, the last character of that line cannot be selected.

Signed-off-by: CoyAce <AkeyCoy@gmail.com>
2026-01-29 07:19:39 -05:00
runitclean 3af0ebb3a8 text: correct arabic diacritics handling
This commit fixes the association of diacritical marks with the proper script
during text segmentation, as well as fixing the visual position of diacritical
marks. The prior code inverted the Y axis positioning for diacritics, which made
them frequently overlap the glyph they were meant to appear above or below.

Signed-off-by: runitclean <runitclean@disroot.org>
2026-01-15 07:33:20 -05:00
CoyAce 99647591f6 app: [Android] delete redundant dataDirChan
since dataDir() must call after main(), it's redundant to use channel to send path

Signed-off-by: CoyAce <AkeyCoy@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2026-01-07 13:10:33 +01:00
CoyAce e38f80adc6 app/permission: add microphone permissions
Microphone permissions enables the Android permissions RECORD_AUDIO

Signed-off-by: CoyAce <AkeyCoy@gmail.com>
2026-01-07 12:33:12 +01:00
CoyAce 93419a77bd app: [Android] Send focus lost event when the window is backgrounded
Fixes: https://todo.sr.ht/~eliasnaur/gio/679
Signed-off-by: CoyAce <AkeyCoy@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-30 19:10:58 +01:00
Elias Naur c250d7d562 .builds: fix builds
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-28 10:45:38 +01:00
Elias Naur 6e5bbfe8d4 Revert "gpu: replace f32.Point/Rectangle with image.Point/Rectangle"
This reverts commit 36a2fa37c7.

Reason is that text rendering broke on some Android devices[0].

[0] https://lists.sr.ht/~eliasnaur/gio/%3CPH7PR02MB1003858FA1B27C8A53BE96815DDA1A@PH7PR02MB10038.namprd02.prod.outlook.com%3E

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-18 10:13:20 +01:00
CoyAce 42bc707f7c app: [Android] document DataDir limitations
Document that DataDir is not available before main.

References: https://todo.sr.ht/~eliasnaur/gio/229
Signed-off-by: CoyAce <AkeyCoy@gmail.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-18 10:10:04 +01:00
inkeliz 7bcb315ee1 app: add custom scheme support
Now, it's possible to launch one Gio app using a custom URI scheme, such as `gio://some/data`.

This feature is supported on Android, iOS, macOS and Windows, issuing a new transfer.URLEvent,
containing the URL launched. If the program is already open, one transfer.URLEvent will be
sent to the current  app.

Limitations:
On Windows, if the program listen to schemes (compiled with `-schemes`), then just a single
instance of the app can be open. In other words, just a single `myprogram.exe` can
be active.

Security:
Deeplinking have the same level of security of clipboard. Any other software can send such
information and read the content, without any restriction. That should not be used to transfer
sensible data, and can't be fully trusted.

Setup/Compiling:
In order to set the custom scheme, you need to use the new `-schemes` flag in `gogio`, using
as `-schemes gio` will listen to `gio://`.

If you are not using gogio you need to defined some values, which varies for each OS:

macOS/iOS - You need to define the following Properly List:
```
<key>CFBundleURLTypes</key>
<array>
  <dict>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>yourCustomScheme</string>
        </array>
  </dict>
</array>
```

Windows - You need to compiling using -X argument:
```
-ldflags="-X "gioui.org/app.schemesURI=yourCustomScheme" -H=windowsgui"
```

Android - You need to add IntentFilter in GioActivity:
```
<intent-filter>
        <action android:name="android.intent.action.VIEW"></action>
        <category android:name="android.intent.category.DEFAULT"></category>
        <category android:name="android.intent.category.BROWSABLE"></category>
        <data android:scheme="yourCustomScheme"></data>
</intent-filter>
```

That assumes that you still using GioActivity and GioAppDelegate, otherwise more
changes are required.

Events are routed to a new app.Events, which are not linked to a specific window.

Signed-off-by: inkeliz <inkeliz@inkeliz.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-15 22:20:54 +01:00
inkeliz 74671a7f9e app/internal/windows: add SendMessage, FindWindow
Also replace calls to the deprecated StringToUTF16Ptr syscall.

Signed-off-by: inkeliz <inkeliz@inkeliz.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-15 22:20:24 +01:00
inkeliz f48cc2c47f app: [Windows] use the app ID for the window class registry
We're about to send messages between multiple instances of the same
program, and we need something to distinguish Gio programs. Use the
app ID.

Signed-off-by: inkeliz <inkeliz@inkeliz.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-15 22:20:24 +01:00
inkeliz 818061a18a app: remove stale reference to NewWindow in the documentation
Signed-off-by: inkeliz <inkeliz@inkeliz.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-15 22:20:24 +01:00
inkeliz 45963441c1 app: [macOS] run main.main off the main thread when Gio is embedded
When Gio is embedded (such as on Android and iOS), we pretend that the
Go library is the main program by running Go main on the main thread.
To avoid deadlock, `app.Main` returns immediately to relinquish control
of the main thread.

This behaviour is suprising (what if something else runs after `app.Main`?)
and more importantly is not compatible with app global events received
by the main goroutine.

Something had to give, and this change starts a new goroutine for calling
Go's main.

Signed-off-by: inkeliz <inkeliz@inkeliz.com>
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2025-12-15 22:20:24 +01:00
Elias Naur be8d9df848 Revert "app: optimize window context locking"
This reverts commit 3e601e73c4 because it
results in a blank window on Android.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
Fixes: https://todo.sr.ht/~eliasnaur/gio/671
2025-10-17 14:47:26 +02:00
62 changed files with 1803 additions and 364 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Unlicense OR MIT # SPDX-License-Identifier: Unlicense OR MIT
image: debian/testing image: debian/stable
packages: packages:
- clang - clang
- cmake - cmake
+8 -2
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Unlicense OR MIT # SPDX-License-Identifier: Unlicense OR MIT
image: debian/testing image: debian/stable
packages: packages:
- curl - curl
- pkg-config - pkg-config
@@ -18,6 +18,12 @@ packages:
- libxinerama-dev - libxinerama-dev
- libxi-dev - libxi-dev
- libxxf86vm-dev - libxxf86vm-dev
- libegl-mesa0
- libglx-mesa0
- libgl1-mesa-dri
- mesa-libgallium
- libgbm1
- libegl1
- mesa-vulkan-drivers - mesa-vulkan-drivers
- wine - wine
- xvfb - xvfb
@@ -60,7 +66,7 @@ tasks:
- add_32bit_arch: | - add_32bit_arch: |
sudo dpkg --add-architecture i386 sudo dpkg --add-architecture i386
sudo apt-get update sudo apt-get update
sudo apt-get install -y "libwayland-dev:i386" "libx11-dev:i386" "libx11-xcb-dev:i386" "libxkbcommon-dev:i386" "libxkbcommon-x11-dev:i386" "libgles2-mesa-dev:i386" "libegl1-mesa-dev:i386" "libffi-dev:i386" "libvulkan-dev:i386" "libxcursor-dev:i386" sudo apt-get install -y "libwayland-dev:i386" "libx11-dev:i386" "libx11-xcb-dev:i386" "libxkbcommon-dev:i386" "libxkbcommon-x11-dev:i386" "libgles2-mesa-dev:i386" "libegl1-mesa-dev:i386" "libffi-dev:i386" "libvulkan-dev:i386" "libxcursor-dev:i386" "libegl-mesa0:i386" "libglx-mesa0:i386" "libgbm1:i386" "mesa-libgallium:i386" "libgl1-mesa-dri:i386"
- test_gio: | - test_gio: |
cd gio cd gio
go test -race ./... go test -race ./...
+17
View File
@@ -4,6 +4,7 @@ package org.gioui;
import android.app.Activity; import android.app.Activity;
import android.os.Bundle; import android.os.Bundle;
import android.content.Intent;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.View; import android.view.View;
@@ -29,6 +30,7 @@ public final class GioActivity extends Activity {
layer.addView(view); layer.addView(view);
setContentView(layer); setContentView(layer);
onNewIntent(this.getIntent());
} }
@Override public void onDestroy() { @Override public void onDestroy() {
@@ -46,6 +48,16 @@ public final class GioActivity extends Activity {
super.onStop(); super.onStop();
} }
@Override public void onPause() {
super.onPause();
view.pause();
}
@Override public void onResume() {
super.onResume();
view.resume();
}
@Override public void onConfigurationChanged(Configuration c) { @Override public void onConfigurationChanged(Configuration c) {
super.onConfigurationChanged(c); super.onConfigurationChanged(c);
view.configurationChanged(); view.configurationChanged();
@@ -60,4 +72,9 @@ public final class GioActivity extends Activity {
if (!view.backPressed()) if (!view.backPressed())
super.onBackPressed(); super.onBackPressed();
} }
@Override protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
view.onIntentEvent(intent);
}
} }
+23 -7
View File
@@ -12,6 +12,7 @@ import android.app.Fragment;
import android.app.FragmentManager; import android.app.FragmentManager;
import android.app.FragmentTransaction; import android.app.FragmentTransaction;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Matrix; import android.graphics.Matrix;
@@ -61,7 +62,6 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal
private static boolean jniLoaded; private static boolean jniLoaded;
private final SurfaceHolder.Callback surfCallbacks; private final SurfaceHolder.Callback surfCallbacks;
private final View.OnFocusChangeListener focusCallback;
private final InputMethodManager imm; private final InputMethodManager imm;
private final float scrollXScale; private final float scrollXScale;
private final float scrollYScale; private final float scrollYScale;
@@ -113,12 +113,6 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal
nhandle = onCreateView(this); nhandle = onCreateView(this);
setFocusable(true); setFocusable(true);
setFocusableInTouchMode(true); setFocusableInTouchMode(true);
focusCallback = new View.OnFocusChangeListener() {
@Override public void onFocusChange(View v, boolean focus) {
GioView.this.onFocusChange(nhandle, focus);
}
};
setOnFocusChangeListener(focusCallback);
surfCallbacks = new SurfaceHolder.Callback() { surfCallbacks = new SurfaceHolder.Callback() {
@Override public void surfaceCreated(SurfaceHolder holder) { @Override public void surfaceCreated(SurfaceHolder holder) {
// Ignore; surfaceChanged is guaranteed to be called immediately after this. // Ignore; surfaceChanged is guaranteed to be called immediately after this.
@@ -315,6 +309,15 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal
window.setAttributes(layoutParams); window.setAttributes(layoutParams);
} }
protected void onIntentEvent(Intent intent) {
if (intent == null) {
return;
}
if (intent.getData() != null) {
this.onOpenURI(nhandle, intent.getData().toString());
}
}
@Override protected boolean dispatchHoverEvent(MotionEvent event) { @Override protected boolean dispatchHoverEvent(MotionEvent event) {
if (!accessManager.isTouchExplorationEnabled()) { if (!accessManager.isTouchExplorationEnabled()) {
return super.dispatchHoverEvent(event); return super.dispatchHoverEvent(event);
@@ -472,6 +475,18 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal
} }
} }
public void pause() {
if (nhandle != 0) {
onFocusChange(nhandle, false);
}
}
public void resume() {
if (nhandle != 0) {
onFocusChange(nhandle, true);
}
}
public void destroy() { public void destroy() {
if (nhandle != 0) { if (nhandle != 0) {
onDestroyView(nhandle); onDestroyView(nhandle);
@@ -553,6 +568,7 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal
static private native void onExitTouchExploration(long handle); static private native void onExitTouchExploration(long handle);
static private native void onA11yFocus(long handle, int viewId); static private native void onA11yFocus(long handle, int viewId);
static private native void onClearA11yFocus(long handle, int viewId); static private native void onClearA11yFocus(long handle, int viewId);
static private native void onOpenURI(long handle, String uri);
static private native void imeSetSnippet(long handle, int start, int end); static private native void imeSetSnippet(long handle, int start, int end);
static private native String imeSnippet(long handle); static private native String imeSnippet(long handle);
static private native int imeSnippetStart(long handle); static private native int imeSnippetStart(long handle);
+52 -2
View File
@@ -3,7 +3,10 @@
package app package app
import ( import (
"gioui.org/io/event"
"golang.org/x/net/idna"
"image" "image"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -56,6 +59,15 @@ type FrameEvent struct {
Source input.Source Source input.Source
} }
// URLEvent is generated for external requests to open a URL. Unlike window specific events,
// it is delivered through the [Events] iterator.
//
// In order to receive URLEvents the program must register one or more URL schemes. A scheme can
// be registered using gogio, with the `-schemes` flag.
type URLEvent struct {
URL *url.URL
}
// ViewEvent provides handles to the underlying window objects for the // ViewEvent provides handles to the underlying window objects for the
// current display protocol. // current display protocol.
type ViewEvent interface { type ViewEvent interface {
@@ -118,8 +130,7 @@ func NewContext(ops *op.Ops, e FrameEvent) layout.Context {
// On iOS NSDocumentDirectory is queried. // On iOS NSDocumentDirectory is queried.
// For Android Context.getFilesDir is used. // For Android Context.getFilesDir is used.
// //
// BUG: DataDir blocks on Android until init functions // BUG: On Android, DataDir panics if called before main.
// have completed.
func DataDir() (string, error) { func DataDir() (string, error) {
return dataDir() return dataDir()
} }
@@ -136,7 +147,29 @@ func Main() {
osMain() osMain()
} }
// Events is an iterator that yields events that are not specific to any window,
// such as [URLEvent]. It never returns.
//
// Events must be called by the main goroutine, and replaces the
// call to [Main].
func Events(yield func(event.Event) bool) {
yieldGlobalEvent = yield
osMain()
}
var yieldGlobalEvent func(evt event.Event) bool
func processGlobalEvent(evt event.Event) {
if yieldGlobalEvent == nil {
return
}
if !yieldGlobalEvent(evt) {
yieldGlobalEvent = nil
}
}
func (FrameEvent) ImplementsEvent() {} func (FrameEvent) ImplementsEvent() {}
func (URLEvent) ImplementsEvent() {}
func init() { func init() {
if extraArgs != "" { if extraArgs != "" {
@@ -147,3 +180,20 @@ func init() {
ID = filepath.Base(os.Args[0]) ID = filepath.Base(os.Args[0])
} }
} }
// newURLEvent creates a URLEvent from a raw URL string, handling Punycode decoding.
func newURLEvent(rawurl string) (URLEvent, error) {
u, err := url.Parse(rawurl)
if err != nil {
return URLEvent{}, err
}
u.Host, err = idna.Punycode.ToUnicode(u.Hostname())
if err != nil {
return URLEvent{}, err
}
u, err = url.Parse(u.String())
if err != nil {
return URLEvent{}, err
}
return URLEvent{URL: u}, nil
}
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build !android //go:build !android
// +build !android
package app package app
+6 -1
View File
@@ -48,7 +48,7 @@ For example, to display a blank but otherwise functional window:
func main() { func main() {
go func() { go func() {
w := app.NewWindow() w := new(app.Window)
for { for {
w.Event() w.Event()
} }
@@ -56,6 +56,11 @@ For example, to display a blank but otherwise functional window:
app.Main() app.Main()
} }
# Events
The [Events] iterator yields app-specific events such as [URLEvent]. [Window.Event]
yields events that target a particular window.
# Permissions # Permissions
The packages under gioui.org/app/permission should be imported The packages under gioui.org/app/permission should be imported
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build darwin && ios && nometal //go:build darwin && ios && nometal
// +build darwin,ios,nometal
package app package app
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build darwin && !ios && nometal //go:build darwin && !ios && nometal
// +build darwin,!ios,nometal
package app package app
-3
View File
@@ -1,8 +1,5 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build go1.18
// +build go1.18
package app package app
import ( import (
+41 -4
View File
@@ -108,6 +108,12 @@ type MonitorInfo struct {
Flags uint32 Flags uint32
} }
type CopyDataStruct struct {
DwData uintptr
CbData uint32
LpData uintptr
}
type POINTER_INPUT_TYPE int32 type POINTER_INPUT_TYPE int32
const ( const (
@@ -201,7 +207,9 @@ const (
CFS_POINT = 0x0002 CFS_POINT = 0x0002
CFS_CANDIDATEPOS = 0x0040 CFS_CANDIDATEPOS = 0x0040
HWND_TOPMOST = ^(uint32(1) - 1) // -1 HWND_TOP = syscall.Handle(0)
HWND_TOPMOST = ^(syscall.Handle(1) - 1) // -1
HWND_NOTOPMOST = ^(syscall.Handle(2) - 1) // -2
HTCAPTION = 2 HTCAPTION = 2
HTCLIENT = 1 HTCLIENT = 1
@@ -315,6 +323,7 @@ const (
WM_CANCELMODE = 0x001F WM_CANCELMODE = 0x001F
WM_CHAR = 0x0102 WM_CHAR = 0x0102
WM_CLOSE = 0x0010 WM_CLOSE = 0x0010
WM_COPYDATA = 0x004A
WM_CREATE = 0x0001 WM_CREATE = 0x0001
WM_DPICHANGED = 0x02E0 WM_DPICHANGED = 0x02E0
WM_DESTROY = 0x0002 WM_DESTROY = 0x0002
@@ -419,6 +428,7 @@ var (
_DefWindowProc = user32.NewProc("DefWindowProcW") _DefWindowProc = user32.NewProc("DefWindowProcW")
_DestroyWindow = user32.NewProc("DestroyWindow") _DestroyWindow = user32.NewProc("DestroyWindow")
_DispatchMessage = user32.NewProc("DispatchMessageW") _DispatchMessage = user32.NewProc("DispatchMessageW")
_FindWindow = user32.NewProc("FindWindowW")
_EmptyClipboard = user32.NewProc("EmptyClipboard") _EmptyClipboard = user32.NewProc("EmptyClipboard")
_EnableMouseInPointer = user32.NewProc("EnableMouseInPointer") _EnableMouseInPointer = user32.NewProc("EnableMouseInPointer")
_GetWindowRect = user32.NewProc("GetWindowRect") _GetWindowRect = user32.NewProc("GetWindowRect")
@@ -452,6 +462,7 @@ var (
_ReleaseDC = user32.NewProc("ReleaseDC") _ReleaseDC = user32.NewProc("ReleaseDC")
_ScreenToClient = user32.NewProc("ScreenToClient") _ScreenToClient = user32.NewProc("ScreenToClient")
_ShowWindow = user32.NewProc("ShowWindow") _ShowWindow = user32.NewProc("ShowWindow")
_SendMessage = user32.NewProc("SendMessageW")
_SetCapture = user32.NewProc("SetCapture") _SetCapture = user32.NewProc("SetCapture")
_SetCursor = user32.NewProc("SetCursor") _SetCursor = user32.NewProc("SetCursor")
_SetClipboardData = user32.NewProc("SetClipboardData") _SetClipboardData = user32.NewProc("SetClipboardData")
@@ -504,7 +515,10 @@ func CloseClipboard() error {
} }
func CreateWindowEx(dwExStyle uint32, lpClassName uint16, lpWindowName string, dwStyle uint32, x, y, w, h int32, hWndParent, hMenu, hInstance syscall.Handle, lpParam uintptr) (syscall.Handle, error) { func CreateWindowEx(dwExStyle uint32, lpClassName uint16, lpWindowName string, dwStyle uint32, x, y, w, h int32, hWndParent, hMenu, hInstance syscall.Handle, lpParam uintptr) (syscall.Handle, error) {
wname := syscall.StringToUTF16Ptr(lpWindowName) wname, err := syscall.UTF16PtrFromString(lpWindowName)
if err != nil {
return 0, fmt.Errorf("CreateWindowEx failed: %v", err)
}
hwnd, _, err := _CreateWindowEx.Call( hwnd, _, err := _CreateWindowEx.Call(
uintptr(dwExStyle), uintptr(dwExStyle),
uintptr(lpClassName), uintptr(lpClassName),
@@ -576,6 +590,18 @@ func EmptyClipboard() error {
return nil return nil
} }
func FindWindow(lpClassName string) (syscall.Handle, error) {
className, err := syscall.UTF16PtrFromString(lpClassName)
if err != nil {
return 0, fmt.Errorf("FindWindow failed: %v", err)
}
hwnd, _, err := _FindWindow.Call(uintptr(unsafe.Pointer(className)), 0)
if hwnd == 0 {
return 0, fmt.Errorf("FindWindow failed: %v", err)
}
return syscall.Handle(hwnd), nil
}
func GetWindowRect(hwnd syscall.Handle) Rect { func GetWindowRect(hwnd syscall.Handle) Rect {
var r Rect var r Rect
_GetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&r))) _GetWindowRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&r)))
@@ -758,7 +784,7 @@ func SetWindowPlacement(hwnd syscall.Handle, wp *WindowPlacement) {
_SetWindowPlacement.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wp))) _SetWindowPlacement.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wp)))
} }
func SetWindowPos(hwnd syscall.Handle, hwndInsertAfter uint32, x, y, dx, dy int32, style uintptr) { func SetWindowPos(hwnd, hwndInsertAfter syscall.Handle, x, y, dx, dy int32, style uintptr) {
_SetWindowPos.Call(uintptr(hwnd), uintptr(hwndInsertAfter), _SetWindowPos.Call(uintptr(hwnd), uintptr(hwndInsertAfter),
uintptr(x), uintptr(y), uintptr(x), uintptr(y),
uintptr(dx), uintptr(dy), uintptr(dx), uintptr(dy),
@@ -767,7 +793,10 @@ func SetWindowPos(hwnd syscall.Handle, hwndInsertAfter uint32, x, y, dx, dy int3
} }
func SetWindowText(hwnd syscall.Handle, title string) { func SetWindowText(hwnd syscall.Handle, title string) {
wname := syscall.StringToUTF16Ptr(title) wname, err := syscall.UTF16PtrFromString(title)
if err != nil {
panic(err)
}
_SetWindowText.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wname))) _SetWindowText.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wname)))
} }
@@ -883,6 +912,14 @@ func ReleaseDC(hdc syscall.Handle) {
_ReleaseDC.Call(uintptr(hdc)) _ReleaseDC.Call(uintptr(hdc))
} }
func SendMessage(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) error {
r, _, err := _SendMessage.Call(uintptr(hwnd), uintptr(msg), wParam, lParam)
if r == 0 {
return fmt.Errorf("SendMessage failed: %v", err)
}
return nil
}
func SetForegroundWindow(hwnd syscall.Handle) { func SetForegroundWindow(hwnd syscall.Handle) {
_SetForegroundWindow.Call(uintptr(hwnd)) _SetForegroundWindow.Call(uintptr(hwnd))
} }
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build darwin && ios //go:build darwin && ios
// +build darwin,ios
package app package app
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build !nometal //go:build !nometal
// +build !nometal
package app package app
+3 -1
View File
@@ -45,7 +45,9 @@ type Config struct {
CustomRenderer bool CustomRenderer bool
// Decorated reports whether window decorations are provided automatically. // Decorated reports whether window decorations are provided automatically.
Decorated bool Decorated bool
// Focused reports whether has the keyboard focus. // TopMost windows render above all other non-top-most windows.
TopMost bool
// Focused reports whether the window is focused.
Focused bool Focused bool
// decoHeight is the height of the fallback decoration for platforms such // decoHeight is the height of the fallback decoration for platforms such
// as Wayland that may need fallback client-side decorations. // as Wayland that may need fallback client-side decorations.
+17 -9
View File
@@ -136,6 +136,8 @@ import (
"unicode/utf16" "unicode/utf16"
"unsafe" "unsafe"
"gioui.org/io/transfer"
"gioui.org/internal/f32color" "gioui.org/internal/f32color"
"gioui.org/op" "gioui.org/op"
@@ -146,7 +148,6 @@ import (
"gioui.org/io/pointer" "gioui.org/io/pointer"
"gioui.org/io/semantic" "gioui.org/io/semantic"
"gioui.org/io/system" "gioui.org/io/system"
"gioui.org/io/transfer"
"gioui.org/unit" "gioui.org/unit"
) )
@@ -217,8 +218,6 @@ type AndroidViewEvent struct {
type jvalue uint64 // The largest JNI type fits in 64 bits. type jvalue uint64 // The largest JNI type fits in 64 bits.
var dataDirChan = make(chan string, 1)
var android struct { var android struct {
// mu protects all fields of this structure. However, once a // mu protects all fields of this structure. However, once a
// non-nil jvm is returned from javaVM, all the other fields may // non-nil jvm is returned from javaVM, all the other fields may
@@ -294,8 +293,7 @@ var mainWindow = newWindowRendezvous()
var mainFuncs = make(chan func(env *C.JNIEnv), 1) var mainFuncs = make(chan func(env *C.JNIEnv), 1)
var ( var (
dataDirOnce sync.Once dataPath string
dataPath string
) )
var ( var (
@@ -342,9 +340,9 @@ func (w *window) NewContext() (context, error) {
} }
func dataDir() (string, error) { func dataDir() (string, error) {
dataDirOnce.Do(func() { if dataPath == "" {
dataPath = <-dataDirChan panic("DataDir isn't valid before main")
}) }
return dataPath, nil return dataPath, nil
} }
@@ -397,7 +395,7 @@ func Java_org_gioui_Gio_runGoMain(env *C.JNIEnv, class C.jclass, jdataDir C.jbyt
os.Setenv("HOME", dataDir) os.Setenv("HOME", dataDir)
} }
dataDirChan <- dataDir dataPath = dataDir
C.jni_ReleaseByteArrayElements(env, jdataDir, dirBytes) C.jni_ReleaseByteArrayElements(env, jdataDir, dirBytes)
runMain() runMain()
@@ -664,6 +662,15 @@ func Java_org_gioui_GioView_onClearA11yFocus(env *C.JNIEnv, class C.jclass, view
} }
} }
//export Java_org_gioui_GioView_onOpenURI
func Java_org_gioui_GioView_onOpenURI(env *C.JNIEnv, class C.jclass, view C.jlong, uri C.jstring) {
evt, err := newURLEvent(goString(env, uri))
if err != nil {
return
}
processGlobalEvent(evt)
}
func (w *window) ProcessEvent(e event.Event) { func (w *window) ProcessEvent(e event.Event) {
w.processEvent(e) w.processEvent(e)
} }
@@ -1318,6 +1325,7 @@ func findClass(env *C.JNIEnv, name string) C.jclass {
} }
func osMain() { func osMain() {
select {}
} }
func newWindow(window *callbacks, options []Option) { func newWindow(window *callbacks, options []Option) {
+14 -4
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build darwin && ios //go:build darwin && ios
// +build darwin,ios
package app package app
@@ -405,11 +404,12 @@ const (
) )
func osMain() { func osMain() {
if !isMainThread() {
panic("app.Main must be run on the main goroutine")
}
switch mainMode { switch mainMode {
case mainModeUndefined: case mainModeUndefined:
if !isMainThread() {
panic("app.Main must be run on the main goroutine")
}
mainMode = mainModeExe mainMode = mainModeExe
var argv []*C.char var argv []*C.char
for _, arg := range os.Args { for _, arg := range os.Args {
@@ -423,6 +423,16 @@ func osMain() {
case mainModeLibrary: case mainModeLibrary:
// Do nothing, we're embedded as a library. // Do nothing, we're embedded as a library.
} }
select {}
}
//export gio_onOpenURI
func gio_onOpenURI(uri C.CFTypeRef) {
evt, err := newURLEvent(nsstringToString(uri))
if err != nil {
return
}
processGlobalEvent(evt)
} }
//export gio_runMain //export gio_runMain
+51 -11
View File
@@ -134,23 +134,59 @@ NSArray<UIKeyCommand *> *_keyCommands;
return gio_layerClass(); return gio_layerClass();
} }
- (void)willMoveToWindow:(UIWindow *)newWindow { - (void)willMoveToWindow:(UIWindow *)newWindow {
self.contentScaleFactor = newWindow.screen.nativeScale;
if (@available(iOS 13.0, *)) {
[self registerSceneNotifications:newWindow];
}else{
[self registerWindowNotifications:newWindow];
}
}
- (void)registerSceneNotifications:(UIWindow *)newWindow {
if (self.window != nil) {
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UISceneDidActivateNotification
object:self.window.windowScene];
[[NSNotificationCenter defaultCenter] removeObserver:self
name:UISceneWillDeactivateNotification
object:self.window.windowScene];
}
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onSceneDidActivate:)
name:UISceneDidActivateNotification
object:newWindow.windowScene];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onSceneWillDeactivate:)
name:UISceneWillDeactivateNotification
object:newWindow.windowScene];
}
- (void)onSceneDidActivate:(NSNotification *)note API_AVAILABLE(ios(13.0)){
onFocus(self.handle, YES);
}
- (void)onSceneWillDeactivate:(NSNotification *)note API_AVAILABLE(ios(13.0)){
onFocus(self.handle, NO);
}
- (void)registerWindowNotifications:(UIWindow *)newWindow {
if (self.window != nil) { if (self.window != nil) {
[[NSNotificationCenter defaultCenter] removeObserver:self [[NSNotificationCenter defaultCenter] removeObserver:self
name:UIWindowDidBecomeKeyNotification name:UIWindowDidBecomeKeyNotification
object:self.window]; object:self.window];
[[NSNotificationCenter defaultCenter] removeObserver:self [[NSNotificationCenter defaultCenter] removeObserver:self
name:UIWindowDidResignKeyNotification name:UIWindowDidResignKeyNotification
object:self.window]; object:self.window];
} }
self.contentScaleFactor = newWindow.screen.nativeScale;
[[NSNotificationCenter defaultCenter] addObserver:self [[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onWindowDidBecomeKey:) selector:@selector(onWindowDidBecomeKey:)
name:UIWindowDidBecomeKeyNotification name:UIWindowDidBecomeKeyNotification
object:newWindow]; object:newWindow];
[[NSNotificationCenter defaultCenter] addObserver:self [[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onWindowDidResignKey:) selector:@selector(onWindowDidResignKey:)
name:UIWindowDidResignKeyNotification name:UIWindowDidResignKeyNotification
object:newWindow]; object:newWindow];
} }
- (void)onWindowDidBecomeKey:(NSNotification *)note { - (void)onWindowDidBecomeKey:(NSNotification *)note {
@@ -293,6 +329,10 @@ void gio_viewSetHandle(CFTypeRef viewRef, uintptr_t handle) {
[self.window makeKeyAndVisible]; [self.window makeKeyAndVisible];
return YES; return YES;
} }
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
gio_onOpenURI((__bridge CFTypeRef)url.absoluteString);
return YES;
}
@end @end
int gio_applicationMain(int argc, char *argv[]) { int gio_applicationMain(int argc, char *argv[]) {
+352 -18
View File
@@ -53,7 +53,8 @@ type window struct {
screenOrientation js.Value screenOrientation js.Value
cleanfuncs []func() cleanfuncs []func()
touches []js.Value touches []js.Value
composing bool composing int
lastCursor int
requestFocus bool requestFocus bool
config Config config Config
@@ -84,6 +85,7 @@ func newWindow(win *callbacks, options []Option) {
clipboard: js.Global().Get("navigator").Get("clipboard"), clipboard: js.Global().Get("navigator").Get("clipboard"),
wakeups: make(chan struct{}, 1), wakeups: make(chan struct{}, 1),
w: win, w: win,
composing: -1,
} }
w.w.SetDriver(w) w.w.SetDriver(w)
w.requestAnimationFrame = w.window.Get("requestAnimationFrame") w.requestAnimationFrame = w.window.Get("requestAnimationFrame")
@@ -130,8 +132,10 @@ func getContainer(doc js.Value) js.Value {
} }
func createTextArea(doc js.Value) js.Value { func createTextArea(doc js.Value) js.Value {
tarea := doc.Call("createElement", "input") tarea := doc.Call("createElement", "textarea")
style := tarea.Get("style") style := tarea.Get("style")
// Position absolute so left/top coordinates actually place the element
style.Set("position", "absolute")
style.Set("width", "1px") style.Set("width", "1px")
style.Set("height", "1px") style.Set("height", "1px")
style.Set("opacity", "0") style.Set("opacity", "0")
@@ -141,6 +145,12 @@ func createTextArea(doc js.Value) js.Value {
tarea.Set("autocorrect", "off") tarea.Set("autocorrect", "off")
tarea.Set("autocapitalize", "off") tarea.Set("autocapitalize", "off")
tarea.Set("spellcheck", false) tarea.Set("spellcheck", false)
// Enable multiline text input for better composition support on some browsers.
tarea.Set("rows", 1)
style.Set("resize", "none")
style.Set("overflow", "hidden")
style.Set("white-space", "pre-wrap")
style.Set("word-break", "normal")
return tarea return tarea
} }
@@ -263,7 +273,15 @@ func (w *window) addEventListeners() {
return nil return nil
}) })
w.addEventListener(w.tarea, "blur", func(this js.Value, args []js.Value) interface{} { w.addEventListener(w.tarea, "blur", func(this js.Value, args []js.Value) interface{} {
if w.composing != -1 {
// If we're composing, try to cancel.
// On Javascript is not possible to cancel the composition once started.
w.w.SetComposingRegion(key.Range{Start: -1, End: -1})
w.composing = -1
}
w.config.Focused = false w.config.Focused = false
w.lastCursor = 0 // Reset cursor tracking on blur
w.processEvent(ConfigEvent{Config: w.config}) w.processEvent(ConfigEvent{Config: w.config})
w.blur() w.blur()
return nil return nil
@@ -277,19 +295,205 @@ func (w *window) addEventListeners() {
return nil return nil
}) })
w.addEventListener(w.tarea, "compositionstart", func(this js.Value, args []js.Value) interface{} { w.addEventListener(w.tarea, "compositionstart", func(this js.Value, args []js.Value) interface{} {
w.composing = true st := w.w.EditorState()
sel := st.Selection.Range
if sel.Start == -1 {
sel.Start = 0
sel.End = 0
}
w.w.SetEditorSnippet(key.Range{Start: sel.Start, End: sel.End})
w.composing = sel.Start
return nil return nil
}) })
w.addEventListener(w.tarea, "compositionend", func(this js.Value, args []js.Value) interface{} { w.addEventListener(w.tarea, "compositionend", func(this js.Value, args []js.Value) interface{} {
w.composing = false finalText := w.tarea.Get("value").String()
w.flushInput()
if w.composing != -1 && finalText != "" {
// Replace the entire composition range with the final text.
compEnd := w.composing + utf8.RuneCountInString(finalText)
replaceRange := key.Range{Start: w.composing, End: compEnd}
w.w.EditorReplace(replaceRange, finalText)
w.w.SetComposingRegion(key.Range{Start: -1, End: -1})
// Position cursor after the final composition text.
newEnd := w.composing + utf8.RuneCountInString(finalText)
w.w.SetEditorSelection(key.Range{Start: newEnd, End: newEnd})
}
w.composing = -1
w.tarea.Set("value", "")
return nil return nil
}) })
w.addEventListener(w.tarea, "input", func(this js.Value, args []js.Value) interface{} { w.addEventListener(w.tarea, "input", func(this js.Value, args []js.Value) interface{} {
if w.composing { e := args[0]
return nil inputType := e.Get("inputType").String()
dataVal := e.Get("data")
var data string
if dataVal.Truthy() {
data = dataVal.String()
} }
w.flushInput()
// Get the current textarea value.
tareaValue := w.tarea.Get("value").String()
st := w.w.EditorState()
sel := st.Selection.Range
var absStart, absEnd int
snippetStart := st.Snippet.Range.Start
snippetEnd := st.Snippet.Range.End
cursorPos := sel.Start
selectionEnd := sel.End
if cursorPos < 0 {
cursorPos = 0
selectionEnd = 0
}
// Check if we need to expand the snippet to include the range.
if st.Snippet.Range.Start == 0 && st.Snippet.Range.End == 0 && tareaValue != "" {
// Empty snippet - set it to include the selection/cursor.
w.w.SetEditorSnippet(key.Range{Start: cursorPos, End: selectionEnd})
absStart = cursorPos
absEnd = selectionEnd
} else if cursorPos < snippetStart || selectionEnd > snippetEnd {
// Selection is outside the snippet
newStart := snippetStart
newEnd := snippetEnd
if cursorPos < newStart {
newStart = cursorPos
}
if selectionEnd > newEnd {
newEnd = selectionEnd
}
w.w.SetEditorSnippet(key.Range{Start: newStart, End: newEnd})
// Refresh state after snippet update.
st = w.w.EditorState()
// Use the selection range directly.
absStart = cursorPos
absEnd = selectionEnd
} else {
// Selection is within snippet to absolute positions.
absStart = cursorPos
absEnd = selectionEnd
}
switch inputType {
case "insertCompositionText":
if w.composing == -1 {
break
}
compEnd := absEnd
if compEnd < w.composing {
compEnd = w.composing
}
replaceRange := key.Range{Start: w.composing, End: compEnd}
w.w.EditorReplace(replaceRange, data)
newEnd := w.composing + utf8.RuneCountInString(data)
w.w.SetComposingRegion(key.Range{Start: w.composing, End: newEnd})
w.w.SetEditorSelection(key.Range{Start: newEnd, End: newEnd})
case "deleteContentBackward", "deleteContentForward", "deleteByCut":
if w.composing != -1 {
compEnd := w.composing + utf8.RuneCountInString(tareaValue)
replaceRange := key.Range{Start: w.composing, End: compEnd}
w.w.EditorReplace(replaceRange, tareaValue)
newEnd := w.composing + utf8.RuneCountInString(tareaValue)
w.w.SetComposingRegion(key.Range{Start: w.composing, End: newEnd})
w.w.SetEditorSelection(key.Range{Start: newEnd, End: newEnd})
} else {
replaceRange := key.Range{Start: absStart, End: absEnd}
w.w.EditorReplace(replaceRange, "")
w.w.SetEditorSelection(key.Range{Start: absStart, End: absStart})
}
case "insertReplacementText":
if w.composing != -1 {
// During composition, replace the entire composition.
compEnd := w.composing + utf8.RuneCountInString(data)
replaceRange := key.Range{Start: w.composing, End: compEnd}
w.w.EditorReplace(replaceRange, data)
newEnd := w.composing + utf8.RuneCountInString(data)
w.w.SetComposingRegion(key.Range{Start: -1, End: -1})
w.w.SetEditorSelection(key.Range{Start: newEnd, End: newEnd})
w.composing = -1
w.lastCursor = newEnd
} else {
// Safari sends "insertReplacementText" for autocorrect, but the cursor is at the end of the word, so we need to find the word start.
insertLen := utf8.RuneCountInString(data)
wordStart := absStart
if absStart > snippetStart {
relPos := absStart - snippetStart
snippetRunes := []rune(st.Snippet.Text)
for i := relPos - 1; i >= 0; i-- {
if i >= len(snippetRunes) {
continue
}
r := snippetRunes[i]
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
break
}
wordStart = snippetStart + i
}
}
replaceRange := key.Range{Start: wordStart, End: absStart}
w.w.EditorReplace(replaceRange, data)
newCursor := wordStart + insertLen
w.w.SetEditorSelection(key.Range{Start: newCursor, End: newCursor})
w.lastCursor = newCursor
}
case "insertText":
if w.composing != -1 {
compEnd := w.composing + utf8.RuneCountInString(data)
replaceRange := key.Range{Start: w.composing, End: compEnd}
w.w.EditorReplace(replaceRange, data)
newEnd := w.composing + utf8.RuneCountInString(data)
w.w.SetComposingRegion(key.Range{Start: -1, End: -1})
w.w.SetEditorSelection(key.Range{Start: newEnd, End: newEnd})
w.composing = -1
w.lastCursor = newEnd
} else {
insertLen := utf8.RuneCountInString(data)
replaceRange := key.Range{Start: absStart, End: absStart}
if absStart != absEnd {
replaceRange = key.Range{Start: absStart, End: absEnd}
}
newCursor := replaceRange.Start + insertLen
w.w.EditorReplace(replaceRange, data)
w.w.SetEditorSelection(key.Range{Start: newCursor, End: newCursor})
w.lastCursor = newCursor
}
default: // paste and other input types
if w.composing != -1 {
compEnd := w.composing + utf8.RuneCountInString(tareaValue)
replaceRange := key.Range{Start: w.composing, End: compEnd}
w.w.EditorReplace(replaceRange, tareaValue)
newEnd := w.composing + utf8.RuneCountInString(tareaValue)
w.w.SetComposingRegion(key.Range{Start: w.composing, End: newEnd})
w.w.SetEditorSelection(key.Range{Start: newEnd, End: newEnd})
} else {
replaceRange := key.Range{Start: absStart, End: absEnd}
w.w.EditorReplace(replaceRange, tareaValue)
newCursor := absStart + utf8.RuneCountInString(tareaValue)
w.w.SetEditorSelection(key.Range{Start: newCursor, End: newCursor})
}
}
return nil return nil
}) })
w.addEventListener(w.tarea, "paste", func(this js.Value, args []js.Value) interface{} { w.addEventListener(w.tarea, "paste", func(this js.Value, args []js.Value) interface{} {
@@ -306,12 +510,6 @@ func (w *window) addHistory() {
w.browserHistory.Call("pushState", nil, nil, w.window.Get("location").Get("href")) w.browserHistory.Call("pushState", nil, nil, w.window.Get("location").Get("href"))
} }
func (w *window) flushInput() {
val := w.tarea.Get("value").String()
w.tarea.Set("value", "")
w.w.EditorInsert(string(val))
}
func (w *window) blur() { func (w *window) blur() {
w.tarea.Call("blur") w.tarea.Call("blur")
w.requestFocus = false w.requestFocus = false
@@ -343,11 +541,49 @@ func (w *window) keyboard(hint key.InputHint) {
m = "text" m = "text"
} }
w.tarea.Set("inputMode", m) w.tarea.Set("inputMode", m)
// Update autocomplete / autocorrect attributes.
var autocomplete, autocorrect, autocapitalize string
var spellcheck bool
switch hint {
case key.HintAny, key.HintText:
autocomplete, autocorrect, autocapitalize, spellcheck = "on", "on", "on", true
case key.HintEmail:
autocomplete, autocorrect, autocapitalize, spellcheck = "email", "off", "off", false
case key.HintURL:
autocomplete, autocorrect, autocapitalize, spellcheck = "url", "off", "off", false
case key.HintTelephone:
autocomplete, autocorrect, autocapitalize, spellcheck = "tel", "off", "off", false
case key.HintPassword:
autocomplete, autocorrect, autocapitalize, spellcheck = "current-password", "off", "off", false
default: // key.HintNumeric and others
autocomplete, autocorrect, autocapitalize, spellcheck = "off", "off", "off", false
}
w.tarea.Set("autocomplete", autocomplete)
w.tarea.Set("autocorrect", autocorrect)
w.tarea.Set("autocapitalize", autocapitalize)
w.tarea.Set("spellcheck", spellcheck)
} }
func (w *window) keyEvent(e js.Value, ks key.State) { func (w *window) keyEvent(e js.Value, ks key.State) {
k := e.Get("key").String() k := e.Get("key").String()
if n, ok := translateKey(k); ok { if n, ok := translateKey(k); ok {
if ks == key.Press {
isMod := n == key.NameAlt || n == key.NameCommand || n == key.NameCtrl || n == key.NameShift || n == key.NameSuper
isFunc := n == key.NameUpArrow || n == key.NameDownArrow || n == key.NameLeftArrow || n == key.NameRightArrow ||
n == key.NamePageUp || n == key.NamePageDown || n == key.NameHome || n == key.NameEnd ||
n == key.NameEscape || n == key.NameReturn || n == key.NameEnter || n == key.NameTab ||
n == key.NameDeleteBackward || n == key.NameDeleteForward
if isMod || isFunc {
// Gio will request the browser to change the selection/carret position natively.
e.Call("preventDefault")
}
}
cmd := key.Event{ cmd := key.Event{
Name: n, Name: n,
Modifiers: modifiersFor(e), Modifiers: modifiersFor(e),
@@ -414,6 +650,12 @@ func modifiersFor(e js.Value) key.Modifiers {
if e.Call("getModifierState", "Shift").Bool() { if e.Call("getModifierState", "Shift").Bool() {
mods |= key.ModShift mods |= key.ModShift
} }
if e.Call("getModifierState", "Meta").Bool() {
mods |= key.ModCommand
}
if e.Call("getModifierState", "OS").Bool() {
mods |= key.ModSuper
}
return mods return mods
} }
@@ -434,6 +676,9 @@ func (w *window) touchEvent(kind pointer.Kind, e js.Value) {
if e.Get("ctrlKey").Bool() { if e.Get("ctrlKey").Bool() {
mods |= key.ModCtrl mods |= key.ModCtrl
} }
if e.Get("metaKey").Bool() {
mods |= key.ModCommand
}
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
touch := changedTouches.Index(i) touch := changedTouches.Index(i)
pid := w.touchIDFor(touch) pid := w.touchIDFor(touch)
@@ -521,7 +766,90 @@ func (w *window) funcOf(f func(this js.Value, args []js.Value) interface{}) js.F
return jsf return jsf
} }
func (w *window) EditorStateChanged(old, new editorState) {} func (w *window) EditorStateChanged(old, new editorState) {
if w.composing != -1 {
// Do not interfere with browser state while composing.
// On Javascript is not possible to cancel the composition once started!
return
}
// Update textarea value to match the snippet.
if old.Snippet != new.Snippet {
w.tarea.Set("value", new.Snippet.Text)
}
// Update selection to match Gio's selection.
if old.Selection.Range != new.Selection.Range || old.Snippet != new.Snippet {
if new.Selection.Range.Start != -1 && new.Selection.Range.End != -1 {
// Calculate selection positions relative to snippet start.
// The textarea contains only the snippet text.
snippetStart := new.Snippet.Range.Start
snippetEnd := new.Snippet.Range.End
selStart := new.Selection.Range.Start
selEnd := new.Selection.Range.End
if selStart < snippetStart {
selStart = snippetStart
}
if selStart > snippetEnd {
selStart = snippetEnd
}
if selEnd < snippetStart {
selEnd = snippetStart
}
if selEnd > snippetEnd {
selEnd = snippetEnd
}
// Convert absolute rune positions to UTF-16 positions for the textarea.
startUTF16 := new.UTF16Index(selStart)
endUTF16 := new.UTF16Index(selEnd)
// Convert to snippet-relative UTF-16 positions.
snippetStartUTF16 := new.UTF16Index(snippetStart)
start := startUTF16 - snippetStartUTF16
end := endUTF16 - snippetStartUTF16
if start < 0 {
start = 0
}
if end < 0 {
end = 0
}
// Calculate max UTF-16 length of snippet text.
textLen := new.UTF16Index(snippetEnd) - snippetStartUTF16
if start > textLen {
start = textLen
}
if end > textLen {
end = textLen
}
if start > end {
start, end = end, start
}
w.tarea.Set("selectionStart", start)
w.tarea.Set("selectionEnd", end)
}
}
// Move DOM element to position the caret.
if old.Selection.Caret != new.Selection.Caret || old.Selection.Transform != new.Selection.Transform {
pos := new.Selection.Transform.Transform(new.Selection.Caret.Pos.Add(f32.Pt(0, new.Selection.Caret.Descent)))
bounds := w.cnv.Call("getBoundingClientRect")
left := bounds.Get("left").Float() + float64(pos.X)/float64(w.scale)
top := bounds.Get("top").Float() + float64(pos.Y-new.Selection.Caret.Ascent)/float64(w.scale)
height := float64(new.Selection.Caret.Ascent+new.Selection.Caret.Descent) / float64(w.scale)
style := w.tarea.Get("style")
style.Set("left", fmt.Sprintf("%fpx", left))
style.Set("top", fmt.Sprintf("%fpx", top))
style.Set("height", fmt.Sprintf("%fpx", height))
style.Set("width", "1px")
}
}
func (w *window) SetAnimating(anim bool) { func (w *window) SetAnimating(anim bool) {
w.animating = anim w.animating = anim
@@ -535,10 +863,9 @@ func (w *window) ReadClipboard() {
if w.clipboard.IsUndefined() { if w.clipboard.IsUndefined() {
return return
} }
if w.clipboard.Get("readText").IsUndefined() { if w.clipboard.Get("readText").Truthy() {
return w.clipboard.Call("readText").Call("then", w.clipboardCallback)
} }
w.clipboard.Call("readText", w.clipboard).Call("then", w.clipboardCallback)
} }
func (w *window) WriteClipboard(mime string, s []byte) { func (w *window) WriteClipboard(mime string, s []byte) {
@@ -621,6 +948,13 @@ func (w *window) ShowTextInput(show bool) {
if show { if show {
w.focus() w.focus()
} else { } else {
// If we're composing, end composition first by clearing the textarea.
// That is a attempt to force the browser to end composition.
if w.composing != -1 {
w.tarea.Set("value", "")
w.composing = -1
w.w.SetComposingRegion(key.Range{Start: -1, End: -1})
}
w.blur() w.blur()
} }
} }
+20
View File
@@ -241,6 +241,13 @@ static void setTitle(CFTypeRef windowRef, CFTypeRef titleRef) {
} }
} }
static void setWindowLevel(CFTypeRef windowRef, NSWindowLevel level) {
@autoreleasepool {
NSWindow *window = (__bridge NSWindow *)windowRef;
window.level = level;
}
}
static int isWindowZoomed(CFTypeRef windowRef) { static int isWindowZoomed(CFTypeRef windowRef) {
@autoreleasepool { @autoreleasepool {
NSWindow *window = (__bridge NSWindow *)windowRef; NSWindow *window = (__bridge NSWindow *)windowRef;
@@ -495,6 +502,9 @@ func (w *window) Configure(options []Option) {
barTrans = C.YES barTrans = C.YES
titleVis = C.NSWindowTitleHidden titleVis = C.NSWindowTitleHidden
} }
if cnf.TopMost {
C.setWindowLevel(window, C.NSFloatingWindowLevel)
}
C.setWindowTitlebarAppearsTransparent(window, barTrans) C.setWindowTitlebarAppearsTransparent(window, barTrans)
C.setWindowTitleVisibility(window, titleVis) C.setWindowTitleVisibility(window, titleVis)
C.setWindowStyleMask(window, mask) C.setWindowStyleMask(window, mask)
@@ -997,6 +1007,16 @@ func gio_onFinishLaunching() {
close(launched) close(launched)
} }
//export gio_onOpenURI
func gio_onOpenURI(uri C.CFTypeRef) {
evt, err := newURLEvent(nsstringToString(uri))
if err != nil {
return
}
processGlobalEvent(evt)
}
func newWindow(win *callbacks, options []Option) { func newWindow(win *callbacks, options []Option) {
<-launched <-launched
res := make(chan struct{}) res := make(chan struct{})
+5
View File
@@ -421,6 +421,11 @@ void gio_viewSetHandle(CFTypeRef viewRef, uintptr_t handle) {
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
[NSApp activateIgnoringOtherApps:YES]; [NSApp activateIgnoringOtherApps:YES];
} }
- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
for (NSURL *url in urls) {
gio_onOpenURI((__bridge CFTypeRef)url.absoluteString);
}
}
@end @end
void gio_main() { void gio_main() {
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build (linux && !android) || freebsd || openbsd //go:build (linux && !android) || freebsd || openbsd
// +build linux,!android freebsd openbsd
package app package app
+294 -45
View File
@@ -5,8 +5,12 @@ package app
import ( import (
"errors" "errors"
"fmt" "fmt"
"gioui.org/io/transfer"
syscall "golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"image" "image"
"io" "io"
"os"
"runtime" "runtime"
"sort" "sort"
"strings" "strings"
@@ -16,8 +20,6 @@ import (
"unicode/utf8" "unicode/utf8"
"unsafe" "unsafe"
syscall "golang.org/x/sys/windows"
"gioui.org/app/internal/windows" "gioui.org/app/internal/windows"
"gioui.org/op" "gioui.org/op"
"gioui.org/unit" "gioui.org/unit"
@@ -28,7 +30,6 @@ import (
"gioui.org/io/key" "gioui.org/io/key"
"gioui.org/io/pointer" "gioui.org/io/pointer"
"gioui.org/io/system" "gioui.org/io/system"
"gioui.org/io/transfer"
) )
type Win32ViewEvent struct { type Win32ViewEvent struct {
@@ -56,6 +57,8 @@ type window struct {
const _WM_WAKEUP = windows.WM_USER + iota const _WM_WAKEUP = windows.WM_USER + iota
const copyDataURLType = 0xffffff00
type gpuAPI struct { type gpuAPI struct {
priority int priority int
initializer func(w *window) (context, error) initializer func(w *window) (context, error)
@@ -81,6 +84,7 @@ var resources struct {
} }
func osMain() { func osMain() {
processURLEvent(startupURI())
select {} select {}
} }
@@ -132,13 +136,19 @@ func initResources() error {
} }
resources.cursor = c resources.cursor = c
icon, _ := windows.LoadImage(hInst, iconID, windows.IMAGE_ICON, 0, 0, windows.LR_DEFAULTSIZE|windows.LR_SHARED) icon, _ := windows.LoadImage(hInst, iconID, windows.IMAGE_ICON, 0, 0, windows.LR_DEFAULTSIZE|windows.LR_SHARED)
appid, err := syscall.UTF16PtrFromString(ID)
if err != nil {
return err
}
wcls := windows.WndClassEx{ wcls := windows.WndClassEx{
CbSize: uint32(unsafe.Sizeof(windows.WndClassEx{})), CbSize: uint32(unsafe.Sizeof(windows.WndClassEx{})),
Style: windows.CS_HREDRAW | windows.CS_VREDRAW | windows.CS_OWNDC, Style: windows.CS_HREDRAW | windows.CS_VREDRAW | windows.CS_OWNDC,
LpfnWndProc: syscall.NewCallback(windowProc), LpfnWndProc: syscall.NewCallback(windowProc),
HInstance: hInst, HInstance: hInst,
HIcon: icon, HIcon: icon,
LpszClassName: syscall.StringToUTF16Ptr("GioWindow"), LpszClassName: appid,
} }
cls, err := windows.RegisterClassEx(&wcls) cls, err := windows.RegisterClassEx(&wcls)
if err != nil { if err != nil {
@@ -359,8 +369,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
w.update() w.update()
case windows.WM_WINDOWPOSCHANGED: case windows.WM_WINDOWPOSCHANGED:
w.update() w.update()
case windows.WM_SIZE: return 0
w.update()
case windows.WM_GETMINMAXINFO: case windows.WM_GETMINMAXINFO:
mm := (*windows.MinMaxInfo)(unsafe.Pointer(lParam)) mm := (*windows.MinMaxInfo)(unsafe.Pointer(lParam))
@@ -403,6 +412,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
icaret := image.Pt(int(caret.X+.5), int(caret.Y+.5)) icaret := image.Pt(int(caret.X+.5), int(caret.Y+.5))
windows.ImmSetCompositionWindow(imc, icaret.X, icaret.Y) windows.ImmSetCompositionWindow(imc, icaret.X, icaret.Y)
windows.ImmSetCandidateWindow(imc, icaret.X, icaret.Y) windows.ImmSetCandidateWindow(imc, icaret.X, icaret.Y)
return windows.TRUE
case windows.WM_IME_COMPOSITION: case windows.WM_IME_COMPOSITION:
imc := windows.ImmGetContext(w.hwnd) imc := windows.ImmGetContext(w.hwnd)
if imc == 0 { if imc == 0 {
@@ -446,6 +456,20 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
case windows.WM_IME_ENDCOMPOSITION: case windows.WM_IME_ENDCOMPOSITION:
w.w.SetComposingRegion(key.Range{Start: -1, End: -1}) w.w.SetComposingRegion(key.Range{Start: -1, End: -1})
return windows.TRUE return windows.TRUE
case windows.WM_COPYDATA:
data := (*windows.CopyDataStruct)(unsafe.Pointer(lParam))
switch data.DwData {
case copyDataURLType:
if schemesURI == "" {
return windows.TRUE
}
uri := syscall.UTF16PtrToString((*uint16)(unsafe.Pointer(data.LpData)))
if processURLEvent(uri) {
w.Perform(system.ActionRaise)
}
return windows.TRUE
}
} }
return windows.DefWindowProc(hwnd, msg, wParam, lParam) return windows.DefWindowProc(hwnd, msg, wParam, lParam)
@@ -471,34 +495,32 @@ func getModifiers() key.Modifiers {
// hitTest returns the non-client area hit by the point, needed to // hitTest returns the non-client area hit by the point, needed to
// process WM_NCHITTEST. // process WM_NCHITTEST.
func (w *window) hitTest(x, y int) uintptr { func (w *window) hitTest(x, y int) uintptr {
if w.config.Mode != Windowed { if w.config.Mode == Windowed {
// Only windowed mode should allow resizing. // Check for resize handle before system actions; otherwise it can be impossible to
return windows.HTCLIENT // resize a custom-decorations window when the system move area is flush with the
} // edge of the window.
// Check for resize handle before system actions; otherwise it can be impossible to top := y <= w.borderSize.Y
// resize a custom-decorations window when the system move area is flush with the bottom := y >= w.config.Size.Y-w.borderSize.Y
// edge of the window. left := x <= w.borderSize.X
top := y <= w.borderSize.Y right := x >= w.config.Size.X-w.borderSize.X
bottom := y >= w.config.Size.Y-w.borderSize.Y switch {
left := x <= w.borderSize.X case top && left:
right := x >= w.config.Size.X-w.borderSize.X return windows.HTTOPLEFT
switch { case top && right:
case top && left: return windows.HTTOPRIGHT
return windows.HTTOPLEFT case bottom && left:
case top && right: return windows.HTBOTTOMLEFT
return windows.HTTOPRIGHT case bottom && right:
case bottom && left: return windows.HTBOTTOMRIGHT
return windows.HTBOTTOMLEFT case top:
case bottom && right: return windows.HTTOP
return windows.HTBOTTOMRIGHT case bottom:
case top: return windows.HTBOTTOM
return windows.HTTOP case left:
case bottom: return windows.HTLEFT
return windows.HTBOTTOM case right:
case left: return windows.HTRIGHT
return windows.HTLEFT }
case right:
return windows.HTRIGHT
} }
p := f32.Pt(float32(x), float32(y)) p := f32.Pt(float32(x), float32(y))
if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove { if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove {
@@ -677,7 +699,13 @@ func (w *window) ReadClipboard() {
w.readClipboard() w.readClipboard()
} }
func (w *window) readClipboard() error { func (w *window) readClipboard() (cerr error) {
defer func() {
if cerr != nil {
w.processDataEvent("")
}
}()
if err := windows.OpenClipboard(w.hwnd); err != nil { if err := windows.OpenClipboard(w.hwnd); err != nil {
return err return err
} }
@@ -692,13 +720,17 @@ func (w *window) readClipboard() error {
} }
defer windows.GlobalUnlock(mem) defer windows.GlobalUnlock(mem)
content := gowindows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr))) content := gowindows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr)))
w.processDataEvent(content)
return nil
}
func (w *window) processDataEvent(content string) {
w.ProcessEvent(transfer.DataEvent{ w.ProcessEvent(transfer.DataEvent{
Type: "application/text", Type: "application/text",
Open: func() io.ReadCloser { Open: func() io.ReadCloser {
return io.NopCloser(strings.NewReader(content)) return io.NopCloser(strings.NewReader(content))
}, },
}) })
return nil
} }
func (w *window) Configure(options []Option) { func (w *window) Configure(options []Option) {
@@ -715,7 +747,16 @@ func (w *window) Configure(options []Option) {
style := windows.GetWindowLong(w.hwnd, windows.GWL_STYLE) style := windows.GetWindowLong(w.hwnd, windows.GWL_STYLE)
var showMode int32 var showMode int32
var x, y, width, height int32 var x, y, width, height int32
swpStyle := uintptr(windows.SWP_NOZORDER | windows.SWP_FRAMECHANGED) swpStyle := uintptr(windows.SWP_FRAMECHANGED)
if cnf.TopMost == w.config.TopMost {
// Don't change the z-order if TopMost didn't change.
swpStyle |= windows.SWP_NOZORDER
}
hwndAfter := windows.HWND_NOTOPMOST
if cnf.TopMost {
hwndAfter = windows.HWND_TOPMOST
}
w.config.TopMost = cnf.TopMost
winStyle := uintptr(windows.WS_OVERLAPPEDWINDOW) winStyle := uintptr(windows.WS_OVERLAPPEDWINDOW)
style &^= winStyle style &^= winStyle
switch cnf.Mode { switch cnf.Mode {
@@ -767,7 +808,7 @@ func (w *window) Configure(options []Option) {
// Note: these invocation all trigger the windows callback method which may process a pending system.ActionCenter // Note: these invocation all trigger the windows callback method which may process a pending system.ActionCenter
// action, so SetWindowPos should come first so as to not "overwrite" system.ActionCenter. // action, so SetWindowPos should come first so as to not "overwrite" system.ActionCenter.
windows.SetWindowPos(w.hwnd, 0, x, y, width, height, swpStyle) windows.SetWindowPos(w.hwnd, hwndAfter, x, y, width, height, swpStyle)
windows.SetWindowLong(w.hwnd, windows.GWL_STYLE, style) windows.SetWindowLong(w.hwnd, windows.GWL_STYLE, style)
windows.ShowWindow(w.hwnd, showMode) windows.ShowWindow(w.hwnd, showMode)
} }
@@ -888,19 +929,15 @@ func (w *window) Perform(acts system.Action) {
y := (mi.Bottom - mi.Top - dy) / 2 y := (mi.Bottom - mi.Top - dy) / 2
windows.SetWindowPos(w.hwnd, 0, x, y, dx, dy, windows.SWP_NOZORDER|windows.SWP_FRAMECHANGED) windows.SetWindowPos(w.hwnd, 0, x, y, dx, dy, windows.SWP_NOZORDER|windows.SWP_FRAMECHANGED)
case system.ActionRaise: case system.ActionRaise:
w.raise() windows.SetForegroundWindow(w.hwnd)
windows.SetWindowPos(w.hwnd, windows.HWND_TOP, 0, 0, 0, 0,
windows.SWP_NOMOVE|windows.SWP_NOSIZE|windows.SWP_SHOWWINDOW)
case system.ActionClose: case system.ActionClose:
windows.PostMessage(w.hwnd, windows.WM_CLOSE, 0, 0) windows.PostMessage(w.hwnd, windows.WM_CLOSE, 0, 0)
} }
}) })
} }
func (w *window) raise() {
windows.SetForegroundWindow(w.hwnd)
windows.SetWindowPos(w.hwnd, windows.HWND_TOPMOST, 0, 0, 0, 0,
windows.SWP_NOMOVE|windows.SWP_NOSIZE|windows.SWP_SHOWWINDOW)
}
func convertKeyCode(code uintptr) (key.Name, bool) { func convertKeyCode(code uintptr) (key.Name, bool) {
if '0' <= code && code <= '9' || 'A' <= code && code <= 'Z' { if '0' <= code && code <= '9' || 'A' <= code && code <= 'Z' {
return key.Name(rune(code)), true return key.Name(rune(code)), true
@@ -1052,3 +1089,215 @@ func getPointerButtons(pi windows.PointerInfo) pointer.Buttons {
return btns return btns
} }
// schemesURI is a list of schemes, comma separated, that must be
// defined using -X compiler ldflag, that used in gogio.
var schemesURI string
func init() {
if schemesURI == "" {
return
}
currentSchemes := strings.Split(schemesURI, ",")
oldSchemes := registeredSchemes(ID)
for _, s := range currentSchemes {
for i, o := range oldSchemes {
if s == o {
oldSchemes = append(oldSchemes[:i], oldSchemes[i+1:]...)
break
}
}
}
if len(oldSchemes) > 0 {
go unregisterSchemes(ID, oldSchemes)
}
if len(currentSchemes) == 0 {
return
}
// On Windows, launching the app using a URI will start a new instance of the app,
// a new window. That behavior, by default, doesn't align with iOS/Android/macOS, where
// the deeplink sends the event to the running app (if any). We are emulating it.
if hwnd, _ := windows.FindWindow(ID); hwnd != 0 {
if u := startupURI(); u != "" {
broadcastURI(hwnd, u)
}
os.Exit(0)
return
}
go registerSchemes(ID, currentSchemes)
}
func startupURI() string {
if len(os.Args) == 3 && os.Args[1] == "-gio_launch_url" {
return os.Args[2]
}
return ""
}
func processURLEvent(rawurl string) bool {
if rawurl == "" {
return false
}
evt, err := newURLEvent(rawurl)
if err != nil {
return false
}
for _, scheme := range strings.Split(schemesURI, ",") {
if strings.EqualFold(scheme, evt.URL.Scheme) {
processGlobalEvent(evt)
return true
}
}
return false
}
func broadcastURI(hwnd syscall.Handle, uri string) {
data, err := syscall.UTF16FromString(uri)
if err != nil {
return // Only happens if uri contains NULL character.
}
pinner := new(runtime.Pinner)
defer pinner.Unpin()
pinner.Pin(unsafe.Pointer(&data[0]))
msg := &windows.CopyDataStruct{
DwData: copyDataURLType,
CbData: uint32(len(data) * int(unsafe.Sizeof(data[0]))),
LpData: uintptr(unsafe.Pointer(unsafe.SliceData(data))),
}
pinner.Pin(unsafe.Pointer(msg))
// SendMessage blocks until the message is processed.
windows.SendMessage(hwnd, windows.WM_COPYDATA, 0, uintptr(unsafe.Pointer(msg)))
}
func registeredSchemes(appid string) []string {
meta, err := registry.OpenKey(registry.CURRENT_USER, `Software\\`+appid, registry.ALL_ACCESS)
if err != nil {
return nil
}
defer meta.Close()
schemes, _, _ := meta.GetStringsValue("URISchemes")
return schemes
}
func registerSchemes(appid string, schemes []string) error {
reg := func(scheme string) error {
key, existent, err := registry.CreateKey(registry.CURRENT_USER, `Software\\Classes\\`+scheme, registry.ALL_ACCESS)
if err != nil {
return err
}
defer key.Close()
if existent {
// Check if the existent key belongs to the current application
id, _, err := key.GetStringValue("appid")
if err != nil || id != appid {
return fmt.Errorf("scheme %s already registered by another application", scheme)
}
}
path, err := os.Executable()
if err != nil {
return err
}
if err = key.SetStringValue("", "URL:"+scheme+" Protocol"); err != nil {
return err
}
if err = key.SetStringValue("URL Protocol", ""); err != nil {
return err
}
if err = key.SetStringValue("appid", appid); err != nil {
return err
}
icon, _, err := registry.CreateKey(key, `DefaultIcon`, registry.ALL_ACCESS)
if err != nil {
return err
}
defer icon.Close()
if err = icon.SetStringValue("", `"`+path+`",1`); err != nil {
return err
}
cmd, _, err := registry.CreateKey(key, `shell\\open\\command`, registry.ALL_ACCESS)
if err != nil {
return err
}
defer cmd.Close()
if err = cmd.SetStringValue("", `"`+path+`" -gio_launch_url "%1"`); err != nil {
return err
}
return nil
}
for _, scheme := range schemes {
if scheme == "" {
continue // just in case
}
if err := reg(scheme); err != nil {
return err
}
}
meta, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\\`+appid, registry.ALL_ACCESS)
if err != nil {
return err
}
defer meta.Close()
if err = meta.SetStringsValue("URISchemes", schemes); err != nil {
return err
}
return nil
}
func unregisterSchemes(appid string, schemes []string) {
classes, err := registry.OpenKey(registry.CURRENT_USER, `Software\\Classes`, registry.ALL_ACCESS)
if err != nil {
return
}
defer classes.Close()
for _, scheme := range schemes {
if scheme == "" {
continue // just in case
}
key, err := registry.OpenKey(classes, scheme, registry.ALL_ACCESS)
if err != nil {
continue
}
id, _, err := key.GetStringValue("appid")
if err == nil && id != appid {
continue
}
for _, k := range []string{`DefaultIcon`, `shell\\open\\command`, `shell\\open`, `shell`} {
registry.DeleteKey(key, k)
}
if err := key.Close(); err != nil {
continue
}
registry.DeleteKey(classes, scheme)
}
}
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: Unlicense OR MIT
/*
Package microphone implements permissions to access microphone hardware.
# Android
The following entries will be added to AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
RECORD_AUDIO is a "dangerous" permission. See documentation for package
gioui.org/app/permission for more information.
*/
package microphone
+1 -2
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build android || (darwin && ios) //go:build android || (darwin && ios)
// +build android darwin,ios
package app package app
@@ -25,6 +24,6 @@ func runMain() {
// Indirect call, since the linker does not know the address of main when // Indirect call, since the linker does not know the address of main when
// laying down this package. // laying down this package.
fn := mainMain fn := mainMain
fn() go fn()
}) })
} }
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build !novulkan //go:build !novulkan
// +build !novulkan
package app package app
+19 -5
View File
@@ -142,11 +142,8 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
if w.gpu == nil && !w.nocontext { if w.gpu == nil && !w.nocontext {
var err error var err error
if w.ctx == nil { if w.ctx == nil {
if w.ctx, err = w.driver.NewContext(); err != nil { w.ctx, err = w.driver.NewContext()
return err if err != nil {
}
if err = w.ctx.Lock(); err != nil {
w.destroyGPU()
return err return err
} }
sync = true sync = true
@@ -166,6 +163,12 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
return err return err
} }
} }
if w.ctx != nil {
if err := w.ctx.Lock(); err != nil {
w.destroyGPU()
return err
}
}
if w.gpu == nil && !w.nocontext { if w.gpu == nil && !w.nocontext {
gpu, err := gpu.New(w.ctx.API()) gpu, err := gpu.New(w.ctx.API())
if err != nil { if err != nil {
@@ -197,6 +200,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
var err error var err error
if w.gpu != nil { if w.gpu != nil {
err = w.ctx.Present() err = w.ctx.Present()
w.ctx.Unlock()
} }
return err return err
} }
@@ -435,6 +439,7 @@ func (c *callbacks) EditorState() editorState {
func (c *callbacks) SetComposingRegion(r key.Range) { func (c *callbacks) SetComposingRegion(r key.Range) {
c.w.imeState.compose = r c.w.imeState.compose = r
c.w.driver.ProcessEvent(key.CompositionEvent(r))
} }
func (c *callbacks) EditorInsert(text string) { func (c *callbacks) EditorInsert(text string) {
@@ -965,6 +970,15 @@ func Decorated(enabled bool) Option {
} }
} }
// TopMost windows will be rendered above all other non-top-most windows.
//
// TopMost windows are supported on macOS, Windows.
func TopMost(enabled bool) Option {
return func(_ unit.Metric, cnf *Config) {
cnf.TopMost = enabled
}
}
// flushEvent is sent to detect when the user program // flushEvent is sent to detect when the user program
// has completed processing of all prior events. Its an // has completed processing of all prior events. Its an
// [io/event.Event] but only for internal use. // [io/event.Event] but only for internal use.
+1 -1
View File
@@ -106,7 +106,7 @@ func parseLoader(ld *opentype.Loader) (*fontapi.Font, giofont.Font, error) {
// Face many be invoked any number of times and is safe so long as each return value is // Face many be invoked any number of times and is safe so long as each return value is
// only used by one goroutine. // only used by one goroutine.
func (f Face) Face() *fontapi.Face { func (f Face) Face() *fontapi.Face {
return &fontapi.Face{Font: f.face} return fontapi.NewFace(f.face)
} }
// FontFace returns a text.Font with populated font metadata for the // FontFace returns a text.Font with populated font metadata for the
+3 -12
View File
@@ -61,12 +61,8 @@ func (h *Hover) Update(q input.Source) bool {
h.entered = false h.entered = false
} }
case pointer.Enter: case pointer.Enter:
if !h.entered { h.pid = e.PointerID
h.pid = e.PointerID h.entered = true
}
if h.pid == e.PointerID {
h.entered = true
}
} }
} }
return h.entered return h.entered
@@ -222,12 +218,7 @@ func (c *Click) Update(q input.Source) (ClickEvent, bool) {
if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary { if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary {
break break
} }
if !c.hovered { c.pid = e.PointerID
c.pid = e.PointerID
}
if c.pid != e.PointerID {
break
}
c.pressed = true c.pressed = true
if e.Time-c.clickedAt < doubleClickDuration { if e.Time-c.clickedAt < doubleClickDuration {
c.clicks++ c.clicks++
+72
View File
@@ -100,6 +100,78 @@ func TestMouseClicks(t *testing.T) {
} }
} }
func TestClickPointerIDReassignment(t *testing.T) {
// A Click must accept a Press from a PointerID that differs from the
// one its hovered state was previously associated with. Some backends
// reassign a single physical pointer's ID over its lifetime — e.g. the
// Windows pointer API across focus changes — and locking the gesture
// to the first observed ID would silently drop every subsequent press.
//
// The sequence below puts the gesture into the buggy state through
// public events alone: a press under PointerID 1 starts an active
// press cycle, a Move under PointerID 2 arrives mid-press (which the
// router routes as an Enter for PID 2 but the gesture's Enter handler
// is a no-op for pid while pressed), then PID 1 releases. After this,
// the router has the gesture entered for PID 2 (so the next event
// under PID 2 won't trigger another Enter) but the gesture itself
// still has pid=1.
var click Click
var ops op.Ops
rect := image.Rect(0, 0, 100, 100)
stack := clip.Rect(rect).Push(&ops)
click.Add(&ops)
stack.Pop()
var r input.Router
click.Update(r.Source())
r.Frame(&ops)
drain := func() {
for {
if _, ok := click.Update(r.Source()); !ok {
return
}
}
}
// Press under PointerID 1.
r.Queue(
pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1},
pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 1},
)
drain()
// Move under PointerID 2 while PointerID 1 is still pressed. The
// router records the gesture as entered for PointerID 2 but the
// gesture's Enter handler is a no-op for pid because c.pressed.
r.Queue(pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 2})
drain()
// Release PointerID 1. PointerID 1's press tracking ends; the
// gesture's recorded pid stays at 1.
r.Queue(pointer.Event{Kind: pointer.Release, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1})
drain()
// Press under PointerID 2. The router won't refire Enter for PID 2
// (the gesture is already in PID 2's entered set), so the gesture's
// only chance to refresh its pid is the Press handler itself.
r.Queue(pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 2})
var sawPress bool
for {
ev, ok := click.Update(r.Source())
if !ok {
break
}
if ev.Kind == KindPress {
sawPress = true
}
}
if !sawPress {
t.Fatal("expected KindPress for press under reassigned PointerID; gesture dropped the press because of stale recorded pid")
}
}
func mouseClickEvents(times ...time.Duration) []event.Event { func mouseClickEvents(times ...time.Duration) []event.Event {
press := pointer.Event{ press := pointer.Event{
Kind: pointer.Press, Kind: pointer.Press,
+6 -4
View File
@@ -1,14 +1,16 @@
module gioui.org module gioui.org
go 1.23.8 go 1.24.0
require ( require (
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d
gioui.org/shader v1.0.8 gioui.org/shader v1.0.8
github.com/go-text/typesetting v0.3.0 github.com/go-text/typesetting v0.3.4
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/image v0.26.0 golang.org/x/image v0.26.0
golang.org/x/sys v0.33.0 golang.org/x/sys v0.39.0
golang.org/x/text v0.24.0 golang.org/x/text v0.32.0
) )
require golang.org/x/net v0.48.0
+10 -8
View File
@@ -3,17 +3,19 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8v
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4= github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY= github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+3 -2
View File
@@ -4,7 +4,8 @@ package gpu
import ( import (
"fmt" "fmt"
"image"
"gioui.org/internal/f32"
) )
type textureCacheKey struct { type textureCacheKey struct {
@@ -35,7 +36,7 @@ type opCache struct {
type opCacheValue struct { type opCacheValue struct {
data pathData data pathData
bounds image.Rectangle bounds f32.Rectangle
// the fields below are handled by opCache // the fields below are handled by opCache
key opKey key opKey
keep bool keep bool
+50 -50
View File
@@ -118,17 +118,17 @@ type drawState struct {
} }
type pathOp struct { type pathOp struct {
off image.Point off f32.Point
// rect tracks whether the clip stack can be represented by a // rect tracks whether the clip stack can be represented by a
// pixel-aligned rectangle. // pixel-aligned rectangle.
rect bool rect bool
// clip is the union of all // clip is the union of all
// later clip rectangles. // later clip rectangles.
clip image.Rectangle clip image.Rectangle
bounds image.Rectangle bounds f32.Rectangle
// intersect is the intersection of bounds and all // intersect is the intersection of bounds and all
// previous clip bounds. // previous clip bounds.
intersect image.Rectangle intersect f32.Rectangle
pathKey opKey pathKey opKey
path bool path bool
pathVerts []byte pathVerts []byte
@@ -902,14 +902,16 @@ func (d *drawOps) reset(viewport image.Point) {
d.opacityStack = d.opacityStack[:0] d.opacityStack = d.opacityStack[:0]
} }
func (d *drawOps) collect(root *op.Ops, viewportSize image.Point) { func (d *drawOps) collect(root *op.Ops, viewport image.Point) {
viewport := image.Rectangle{Max: viewportSize} viewf := f32.Rectangle{
Max: f32.Point{X: float32(viewport.X), Y: float32(viewport.Y)},
}
var ops *ops.Ops var ops *ops.Ops
if root != nil { if root != nil {
ops = &root.Internal ops = &root.Internal
} }
d.reader.Reset(ops) d.reader.Reset(ops)
d.collectOps(&d.reader, viewport) d.collectOps(&d.reader, viewf)
} }
func (d *drawOps) buildPaths(ctx driver.Device) { func (d *drawOps) buildPaths(ctx driver.Device) {
@@ -930,7 +932,7 @@ func (d *drawOps) newPathOp() *pathOp {
return &d.pathOpCache[len(d.pathOpCache)-1] return &d.pathOpCache[len(d.pathOpCache)-1]
} }
func (d *drawOps) addClipPath(state *drawState, aux []byte, auxKey opKey, bounds image.Rectangle, off image.Point) { func (d *drawOps) addClipPath(state *drawState, aux []byte, auxKey opKey, bounds f32.Rectangle, off f32.Point) {
npath := d.newPathOp() npath := d.newPathOp()
*npath = pathOp{ *npath = pathOp{
parent: state.cpath, parent: state.cpath,
@@ -971,7 +973,7 @@ func (k opKey) SetTransform(t f32.Affine2D) opKey {
return k return k
} }
func (d *drawOps) collectOps(r *ops.Reader, viewport image.Rectangle) { func (d *drawOps) collectOps(r *ops.Reader, viewport f32.Rectangle) {
var quads quadsOp var quads quadsOp
state := drawState{ state := drawState{
t: f32.AffineId(), t: f32.AffineId(),
@@ -1033,7 +1035,7 @@ loop:
var op ops.ClipOp var op ops.ClipOp
op.Decode(encOp.Data) op.Decode(encOp.Data)
quads.key.outline = op.Outline quads.key.outline = op.Outline
bounds := op.Bounds bounds := f32.FRect(op.Bounds)
trans, off := transformOffset(state.t) trans, off := transformOffset(state.t)
if len(quads.aux) > 0 { if len(quads.aux) > 0 {
// There is a clipping path, build the gpu data and update the // There is a clipping path, build the gpu data and update the
@@ -1045,11 +1047,11 @@ loop:
// Why is this not used for the offset shapes? // Why is this not used for the offset shapes?
bounds = v.bounds bounds = v.bounds
} else { } else {
newPathData, newBounds := d.buildVerts( var pathData []byte
pathData, bounds = d.buildVerts(
quads.aux, trans, quads.key.outline, quads.key.strokeWidth, quads.aux, trans, quads.key.outline, quads.key.strokeWidth,
) )
quads.aux = newPathData quads.aux = pathData
bounds = newBounds.Round()
// add it to the cache, without GPU data, so the transform can be // add it to the cache, without GPU data, so the transform can be
// reused. // reused.
d.pathCache.put(quads.key, opCacheValue{bounds: bounds}) d.pathCache.put(quads.key, opCacheValue{bounds: bounds})
@@ -1084,18 +1086,18 @@ loop:
t, off := transformOffset(state.t) t, off := transformOffset(state.t)
// Fill the clip area, unless the material is a (bounded) image. // Fill the clip area, unless the material is a (bounded) image.
// TODO: Find a tighter bound. // TODO: Find a tighter bound.
inf := int(1e6) inf := float32(1e6)
dst := image.Rect(-inf, -inf, inf, inf) dst := f32.Rect(-inf, -inf, inf, inf)
if state.matType == materialTexture { if state.matType == materialTexture {
sz := state.image.src.Rect.Size() sz := state.image.src.Rect.Size()
dst = image.Rectangle{Max: sz} dst = f32.Rectangle{Max: layout.FPt(sz)}
} }
clipData, bnd, partialTrans := d.boundsForTransformedRect(dst, t) clipData, bnd, partialTrans := d.boundsForTransformedRect(dst, t)
bounds := viewport.Intersect(bnd.Add(off)) cl := viewport.Intersect(bnd.Add(off))
if state.cpath != nil { if state.cpath != nil {
bounds = state.cpath.intersect.Intersect(bounds) cl = state.cpath.intersect.Intersect(cl)
} }
if bounds.Empty() { if cl.Empty() {
continue continue
} }
@@ -1107,6 +1109,7 @@ loop:
d.addClipPath(&state, clipData, k, bnd, off) d.addClipPath(&state, clipData, k, bnd, off)
} }
bounds := cl.Round()
mat := state.materialFor(bnd, off, partialTrans, bounds) mat := state.materialFor(bnd, off, partialTrans, bounds)
rect := state.cpath == nil || state.cpath.rect rect := state.cpath == nil || state.cpath.rect
@@ -1160,7 +1163,7 @@ func expandPathOp(p *pathOp, clip image.Rectangle) {
} }
} }
func (d *drawState) materialFor(rect image.Rectangle, off image.Point, partTrans f32.Affine2D, clip image.Rectangle) material { func (d *drawState) materialFor(rect f32.Rectangle, off f32.Point, partTrans f32.Affine2D, clip image.Rectangle) material {
m := material{ m := material{
opacity: 1., opacity: 1.,
uvTrans: f32.AffineId(), uvTrans: f32.AffineId(),
@@ -1180,7 +1183,7 @@ func (d *drawState) materialFor(rect image.Rectangle, off image.Point, partTrans
m.uvTrans = partTrans.Mul(gradientSpaceTransform(clip, off, d.stop1, d.stop2)) m.uvTrans = partTrans.Mul(gradientSpaceTransform(clip, off, d.stop1, d.stop2))
case materialTexture: case materialTexture:
m.material = materialTexture m.material = materialTexture
dr := rect.Add(off) dr := rect.Add(off).Round()
sz := d.image.src.Bounds().Size() sz := d.image.src.Bounds().Size()
sr := f32.Rectangle{ sr := f32.Rectangle{
Max: f32.Point{ Max: f32.Point{
@@ -1365,7 +1368,7 @@ func texSpaceTransform(r f32.Rectangle, bounds image.Point) (f32.Point, f32.Poin
} }
// gradientSpaceTransform transforms stop1 and stop2 to [(0,0), (1,1)]. // gradientSpaceTransform transforms stop1 and stop2 to [(0,0), (1,1)].
func gradientSpaceTransform(clip image.Rectangle, off image.Point, stop1, stop2 f32.Point) f32.Affine2D { func gradientSpaceTransform(clip image.Rectangle, off f32.Point, stop1, stop2 f32.Point) f32.Affine2D {
d := stop2.Sub(stop1) d := stop2.Sub(stop1)
l := float32(math.Sqrt(float64(d.X*d.X + d.Y*d.Y))) l := float32(math.Sqrt(float64(d.X*d.X + d.Y*d.Y)))
a := float32(math.Atan2(float64(-d.Y), float64(d.X))) a := float32(math.Atan2(float64(-d.Y), float64(d.X)))
@@ -1373,11 +1376,11 @@ func gradientSpaceTransform(clip image.Rectangle, off image.Point, stop1, stop2
// TODO: optimize // TODO: optimize
zp := f32.Point{} zp := f32.Point{}
return f32.AffineId(). return f32.AffineId().
Scale(zp, layout.FPt(clip.Size())). // scale to pixel space Scale(zp, layout.FPt(clip.Size())). // scale to pixel space
Offset(zp.Sub(f32.FPt(off)).Add(layout.FPt(clip.Min))). // offset to clip space Offset(zp.Sub(off).Add(layout.FPt(clip.Min))). // offset to clip space
Offset(zp.Sub(stop1)). // offset to first stop point Offset(zp.Sub(stop1)). // offset to first stop point
Rotate(zp, a). // rotate to align gradient Rotate(zp, a). // rotate to align gradient
Scale(zp, f32.Pt(1/l, 1/l)) // scale gradient to right size Scale(zp, f32.Pt(1/l, 1/l)) // scale gradient to right size
} }
// clipSpaceTransform returns the scale and offset that transforms the given // clipSpaceTransform returns the scale and offset that transforms the given
@@ -1521,7 +1524,7 @@ func decodeToOutlineQuads(qs *quadSplitter, tr f32.Affine2D, pathData []byte) {
} }
// create GPU vertices for transformed r, find the bounds and establish texture transform. // create GPU vertices for transformed r, find the bounds and establish texture transform.
func (d *drawOps) boundsForTransformedRect(r image.Rectangle, tr f32.Affine2D) (aux []byte, bnd image.Rectangle, ptr f32.Affine2D) { func (d *drawOps) boundsForTransformedRect(r f32.Rectangle, tr f32.Affine2D) (aux []byte, bnd f32.Rectangle, ptr f32.Affine2D) {
ptr = f32.AffineId() ptr = f32.AffineId()
if tr == f32.AffineId() { if tr == f32.AffineId() {
// fast-path to allow blitting of pure rectangles. // fast-path to allow blitting of pure rectangles.
@@ -1531,28 +1534,25 @@ func (d *drawOps) boundsForTransformedRect(r image.Rectangle, tr f32.Affine2D) (
// transform all corners, find new bounds // transform all corners, find new bounds
corners := [4]f32.Point{ corners := [4]f32.Point{
tr.Transform(f32.FPt(r.Min)), tr.Transform(f32.Pt(float32(r.Max.X), float32(r.Min.Y))), tr.Transform(r.Min), tr.Transform(f32.Pt(r.Max.X, r.Min.Y)),
tr.Transform(f32.FPt(r.Max)), tr.Transform(f32.Pt(float32(r.Min.X), float32(r.Max.Y))), tr.Transform(r.Max), tr.Transform(f32.Pt(r.Min.X, r.Max.Y)),
}
fBounds := f32.Rectangle{
Min: f32.Pt(math.MaxFloat32, math.MaxFloat32),
Max: f32.Pt(-math.MaxFloat32, -math.MaxFloat32),
} }
bnd.Min = f32.Pt(math.MaxFloat32, math.MaxFloat32)
bnd.Max = f32.Pt(-math.MaxFloat32, -math.MaxFloat32)
for _, c := range corners { for _, c := range corners {
if c.X < fBounds.Min.X { if c.X < bnd.Min.X {
fBounds.Min.X = c.X bnd.Min.X = c.X
} }
if c.Y < fBounds.Min.Y { if c.Y < bnd.Min.Y {
fBounds.Min.Y = c.Y bnd.Min.Y = c.Y
} }
if c.X > fBounds.Max.X { if c.X > bnd.Max.X {
fBounds.Max.X = c.X bnd.Max.X = c.X
} }
if c.Y > fBounds.Max.Y { if c.Y > bnd.Max.Y {
fBounds.Max.Y = c.Y bnd.Max.Y = c.Y
} }
} }
bnd = fBounds.Round()
// build the GPU vertices // build the GPU vertices
l := len(d.vertCache) l := len(d.vertCache)
@@ -1566,12 +1566,12 @@ func (d *drawOps) boundsForTransformedRect(r image.Rectangle, tr f32.Affine2D) (
// establish the transform mapping from bounds rectangle to transformed corners // establish the transform mapping from bounds rectangle to transformed corners
var P1, P2, P3 f32.Point var P1, P2, P3 f32.Point
P1.X = (corners[1].X - fBounds.Min.X) / (fBounds.Max.X - fBounds.Min.X) P1.X = (corners[1].X - bnd.Min.X) / (bnd.Max.X - bnd.Min.X)
P1.Y = (corners[1].Y - fBounds.Min.Y) / (fBounds.Max.Y - fBounds.Min.Y) P1.Y = (corners[1].Y - bnd.Min.Y) / (bnd.Max.Y - bnd.Min.Y)
P2.X = (corners[2].X - fBounds.Min.X) / (fBounds.Max.X - fBounds.Min.X) P2.X = (corners[2].X - bnd.Min.X) / (bnd.Max.X - bnd.Min.X)
P2.Y = (corners[2].Y - fBounds.Min.Y) / (fBounds.Max.Y - fBounds.Min.Y) P2.Y = (corners[2].Y - bnd.Min.Y) / (bnd.Max.Y - bnd.Min.Y)
P3.X = (corners[3].X - fBounds.Min.X) / (fBounds.Max.X - fBounds.Min.X) P3.X = (corners[3].X - bnd.Min.X) / (bnd.Max.X - bnd.Min.X)
P3.Y = (corners[3].Y - fBounds.Min.Y) / (fBounds.Max.Y - fBounds.Min.Y) P3.Y = (corners[3].Y - bnd.Min.Y) / (bnd.Max.Y - bnd.Min.Y)
sx, sy := P2.X-P3.X, P2.Y-P3.Y sx, sy := P2.X-P3.X, P2.Y-P3.Y
ptr = f32.NewAffine2D(sx, P2.X-P1.X, P1.X-sx, sy, P2.Y-P1.Y, P1.Y-sy).Invert() ptr = f32.NewAffine2D(sx, P2.X-P1.X, P1.X-sx, sy, P2.Y-P1.Y, P1.Y-sy).Invert()
@@ -1581,12 +1581,12 @@ func (d *drawOps) boundsForTransformedRect(r image.Rectangle, tr f32.Affine2D) (
// transformOffset a transform into two parts, one which is pure integer offset // transformOffset a transform into two parts, one which is pure integer offset
// and the other representing the scaling, shearing and rotation and fractional // and the other representing the scaling, shearing and rotation and fractional
// offset. // offset.
func transformOffset(t f32.Affine2D) (f32.Affine2D, image.Point) { func transformOffset(t f32.Affine2D) (f32.Affine2D, f32.Point) {
sx, hx, ox, hy, sy, oy := t.Elems() sx, hx, ox, hy, sy, oy := t.Elems()
iox, fox := math.Modf(float64(ox)) iox, fox := math.Modf(float64(ox))
ioy, foy := math.Modf(float64(oy)) ioy, foy := math.Modf(float64(oy))
ft := f32.NewAffine2D(sx, hx, float32(fox), hy, sy, float32(foy)) ft := f32.NewAffine2D(sx, hx, float32(fox), hy, sy, float32(foy))
ip := image.Pt(int(iox), int(ioy)) ip := f32.Pt(float32(iox), float32(ioy))
return ft, ip return ft, ip
} }
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build linux || freebsd || openbsd //go:build linux || freebsd || openbsd
// +build linux freebsd openbsd
package headless package headless
+3 -3
View File
@@ -334,7 +334,7 @@ func (p *pather) begin(sizes []image.Point) {
p.stenciler.begin(sizes) p.stenciler.begin(sizes)
} }
func (p *pather) stencilPath(bounds image.Rectangle, offset image.Point, uv image.Point, data pathData) { func (p *pather) stencilPath(bounds image.Rectangle, offset f32.Point, uv image.Point, data pathData) {
p.stenciler.stencilPath(bounds, offset, uv, data) p.stenciler.stencilPath(bounds, offset, uv, data)
} }
@@ -353,14 +353,14 @@ func (s *stenciler) begin(sizes []image.Point) {
s.fbos.resize(s.ctx, driver.TextureFormatFloat, sizes) s.fbos.resize(s.ctx, driver.TextureFormatFloat, sizes)
} }
func (s *stenciler) stencilPath(bounds image.Rectangle, offset image.Point, uv image.Point, data pathData) { func (s *stenciler) stencilPath(bounds image.Rectangle, offset f32.Point, uv image.Point, data pathData) {
s.ctx.Viewport(uv.X, uv.Y, bounds.Dx(), bounds.Dy()) s.ctx.Viewport(uv.X, uv.Y, bounds.Dx(), bounds.Dy())
// Transform UI coordinates to OpenGL coordinates. // Transform UI coordinates to OpenGL coordinates.
texSize := f32.Point{X: float32(bounds.Dx()), Y: float32(bounds.Dy())} texSize := f32.Point{X: float32(bounds.Dx()), Y: float32(bounds.Dy())}
scale := f32.Point{X: 2 / texSize.X, Y: 2 / texSize.Y} scale := f32.Point{X: 2 / texSize.X, Y: 2 / texSize.Y}
orig := f32.Point{X: -1 - float32(bounds.Min.X)*2/texSize.X, Y: -1 - float32(bounds.Min.Y)*2/texSize.Y} orig := f32.Point{X: -1 - float32(bounds.Min.X)*2/texSize.X, Y: -1 - float32(bounds.Min.Y)*2/texSize.Y}
s.pipeline.uniforms.transform = [4]float32{scale.X, scale.Y, orig.X, orig.Y} s.pipeline.uniforms.transform = [4]float32{scale.X, scale.Y, orig.X, orig.Y}
s.pipeline.uniforms.pathOffset = [2]float32{float32(offset.X), float32(offset.Y)} s.pipeline.uniforms.pathOffset = [2]float32{offset.X, offset.Y}
s.pipeline.pipeline.UploadUniforms(s.ctx) s.pipeline.pipeline.UploadUniforms(s.ctx)
// Draw in batches that fit in uint16 indices. // Draw in batches that fit in uint16 indices.
start := 0 start := 0
+1 -1
View File
@@ -34,7 +34,7 @@ func Parse() {
} }
print := false print := false
silent := false silent := false
for _, part := range strings.Split(val, ",") { for part := range strings.SplitSeq(val, ",") {
switch part { switch part {
case textSubsystem: case textSubsystem:
Text.Store(true) Text.Store(true)
+2 -6
View File
@@ -41,14 +41,10 @@ var (
_eglWaitClient *syscall.Proc _eglWaitClient *syscall.Proc
) )
var loadOnce sync.Once var loadOnce = sync.OnceValue(loadDLLs)
func loadEGL() error { func loadEGL() error {
var err error return loadOnce()
loadOnce.Do(func() {
err = loadDLLs()
})
return err
} }
func loadDLLs() error { func loadDLLs() error {
-1
View File
@@ -1,5 +1,4 @@
//go:build !js //go:build !js
// +build !js
package gl package gl
-1
View File
@@ -152,7 +152,6 @@ func BenchmarkSplitCubic(b *testing.B) {
} }
for _, s := range scenarios { for _, s := range scenarios {
s := s
b.Run(strconv.Itoa(s.segments), func(b *testing.B) { b.Run(strconv.Itoa(s.segments), func(b *testing.B) {
from, ctrl0, ctrl1, to := s.from, s.ctrl0, s.ctrl1, s.to from, ctrl0, ctrl1, to := s.from, s.ctrl0, s.ctrl1, s.to
quads := make([]QuadSegment, s.segments) quads := make([]QuadSegment, s.segments)
+19 -4
View File
@@ -739,6 +739,10 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
state.pointers = nil state.pointers = nil
return state, evts return state, evts
} }
if e.Kind == pointer.Scroll {
// Scroll events are not bound to a pointer; see pointer.Event.PointerID.
return state, q.deliverScrollEvent(handlers, evts, e)
}
state, pidx := state.pointerOf(e) state, pidx := state.pointerOf(e)
p := state.pointers[pidx] p := state.pointers[pidx]
@@ -756,14 +760,13 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
if p.pressed { if p.pressed {
p, evts = q.deliverDragEvent(handlers, p, evts) p, evts = q.deliverDragEvent(handlers, p, evts)
} }
case pointer.Leave:
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
case pointer.Release: case pointer.Release:
evts = q.deliverEvent(handlers, p, evts, e) evts = q.deliverEvent(handlers, p, evts, e)
p.pressed = false p.pressed = false
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e) p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
p, evts = q.deliverDropEvent(handlers, p, evts) p, evts = q.deliverDropEvent(handlers, p, evts)
case pointer.Scroll:
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
evts = q.deliverEvent(handlers, p, evts, e)
default: default:
panic("unsupported pointer event type") panic("unsupported pointer event type")
} }
@@ -780,6 +783,18 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
return state, evts return state, evts
} }
// deliverScrollEvent delivers scroll events to the handlers hit by the event coordinate.
func (q *pointerQueue) deliverScrollEvent(handlers map[event.Tag]*handler, evts []taggedEvent, e pointer.Event) []taggedEvent {
var hits []event.Tag
q.hitTest(e.Position, func(n *hitNode) bool {
if _, ok := handlers[n.tag]; ok {
hits = addHandler(hits, n.tag)
}
return true
})
return q.deliverEvent(handlers, pointerInfo{handlers: hits}, evts, e)
}
func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerInfo, evts []taggedEvent, e pointer.Event) []taggedEvent { func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerInfo, evts []taggedEvent, e pointer.Event) []taggedEvent {
if p.pressed && len(p.handlers) == 1 { if p.pressed && len(p.handlers) == 1 {
e.Priority = pointer.Grabbed e.Priority = pointer.Grabbed
@@ -810,7 +825,7 @@ func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerIn
func (q *pointerQueue) deliverEnterLeaveEvents(handlers map[event.Tag]*handler, cursor pointer.Cursor, p pointerInfo, evts []taggedEvent, e pointer.Event) (pointerInfo, []taggedEvent, pointer.Cursor, bool) { func (q *pointerQueue) deliverEnterLeaveEvents(handlers map[event.Tag]*handler, cursor pointer.Cursor, p pointerInfo, evts []taggedEvent, e pointer.Event) (pointerInfo, []taggedEvent, pointer.Cursor, bool) {
changed := false changed := false
var hits []event.Tag var hits []event.Tag
if e.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press { if e.Kind == pointer.Leave || e.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press {
// Consider non-mouse pointers leaving when they're released. // Consider non-mouse pointers leaving when they're released.
} else { } else {
var transSrc *pointerFilter var transSrc *pointerFilter
+76
View File
@@ -255,6 +255,45 @@ func TestPointerMove(t *testing.T) {
assertEventPointerTypeSequence(t, events(&r, -1, filter(handler2)), pointer.Enter, pointer.Move, pointer.Leave, pointer.Cancel) assertEventPointerTypeSequence(t, events(&r, -1, filter(handler2)), pointer.Enter, pointer.Move, pointer.Leave, pointer.Cancel)
} }
func TestPointerLeave(t *testing.T) {
handler := new(int)
var ops op.Ops
filter := pointer.Filter{
Target: handler,
Kinds: pointer.Move | pointer.Enter | pointer.Leave | pointer.Cancel,
}
defer clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops).Pop()
event.Op(&ops, handler)
var r Router
events(&r, -1, filter)
r.Frame(&ops)
r.Queue(
pointer.Event{
Kind: pointer.Move,
Source: pointer.Mouse,
PointerID: 1,
Position: f32.Pt(50, 50),
},
pointer.Event{
Kind: pointer.Leave,
Source: pointer.Mouse,
PointerID: 1,
Position: f32.Pt(50, 50),
},
)
assertEventPointerTypeSequence(t, events(&r, -1, filter), pointer.Enter, pointer.Move, pointer.Leave)
r.Queue(pointer.Event{
Kind: pointer.Move,
Source: pointer.Mouse,
PointerID: 1,
Position: f32.Pt(50, 50),
})
assertEventPointerTypeSequence(t, events(&r, -1, filter), pointer.Enter, pointer.Move)
}
func TestPointerTypes(t *testing.T) { func TestPointerTypes(t *testing.T) {
handler := new(int) handler := new(int)
var ops op.Ops var ops op.Ops
@@ -1345,3 +1384,40 @@ func events(r *Router, n int, filters ...event.Filter) []event.Event {
} }
return events return events
} }
// TestPointerScrollDoesNotTrackPointer queues two events over two cursor
// regions. The Move puts the live pointer over the button (CursorPointer);
// the Scroll happens over the cell (CursorText) and must not update the
// cursor.
func TestPointerScrollDoesNotTrackPointer(t *testing.T) {
var ops op.Ops
button := clip.Rect(image.Rect(0, 0, 50, 50)).Push(&ops)
pointer.CursorPointer.Add(&ops)
button.Pop()
cell := clip.Rect(image.Rect(100, 0, 200, 50)).Push(&ops)
pointer.CursorText.Add(&ops)
cell.Pop()
var r Router
r.Frame(&ops)
r.Queue(
pointer.Event{
Kind: pointer.Move,
Source: pointer.Mouse,
Position: f32.Pt(25, 25),
},
pointer.Event{
Kind: pointer.Scroll,
Source: pointer.Mouse,
Position: f32.Pt(150, 25),
Scroll: f32.Pt(0, 1),
},
)
if got, want := r.Cursor(), pointer.CursorPointer; got != want {
t.Errorf("got %q, want %q (scroll position must not update the cursor; "+
"the live pointer's last position is what determines it)", got, want)
}
}
+8 -1
View File
@@ -419,7 +419,7 @@ func (f *filter) Merge(f2 filter) {
func (f *filter) Matches(e event.Event) bool { func (f *filter) Matches(e event.Event) bool {
switch e.(type) { switch e.(type) {
case key.FocusEvent, key.SnippetEvent, key.EditEvent, key.SelectionEvent: case key.FocusEvent, key.SnippetEvent, key.EditEvent, key.SelectionEvent, key.CompositionEvent:
return f.focusable return f.focusable
default: default:
return f.pointer.Matches(e) return f.pointer.Matches(e)
@@ -463,6 +463,13 @@ func (q *Router) processEvent(e event.Event, system bool) {
evts = append(evts, taggedEvent{tag: f, event: e}) evts = append(evts, taggedEvent{tag: f, event: e})
} }
q.changeState(e, state, evts) q.changeState(e, state, evts)
case key.CompositionEvent:
e = key.CompositionEvent(rangeNorm(key.Range(e)))
var evts []taggedEvent
if f := state.focus; f != nil {
evts = append(evts, taggedEvent{tag: f, event: e})
}
q.changeState(e, state, evts)
case key.EditEvent, key.FocusEvent, key.SelectionEvent: case key.EditEvent, key.FocusEvent, key.SelectionEvent:
var evts []taggedEvent var evts []taggedEvent
if f := state.focus; f != nil { if f := state.focus; f != nil {
+9 -5
View File
@@ -77,6 +77,9 @@ type Caret struct {
// SelectionEvent is generated when an input method changes the selection. // SelectionEvent is generated when an input method changes the selection.
type SelectionEvent Range type SelectionEvent Range
// CompositionEvent is generated when an input method changes the composing range.
type CompositionEvent Range
// SnippetEvent is generated when the snippet range is updated by an // SnippetEvent is generated when the snippet range is updated by an
// input method. // input method.
type SnippetEvent Range type SnippetEvent Range
@@ -243,11 +246,12 @@ func (h InputHintOp) Add(o *op.Ops) {
data[1] = byte(h.Hint) data[1] = byte(h.Hint)
} }
func (EditEvent) ImplementsEvent() {} func (EditEvent) ImplementsEvent() {}
func (Event) ImplementsEvent() {} func (Event) ImplementsEvent() {}
func (FocusEvent) ImplementsEvent() {} func (FocusEvent) ImplementsEvent() {}
func (SnippetEvent) ImplementsEvent() {} func (CompositionEvent) ImplementsEvent() {}
func (SelectionEvent) ImplementsEvent() {} func (SnippetEvent) ImplementsEvent() {}
func (SelectionEvent) ImplementsEvent() {}
func (FocusCmd) ImplementsCommand() {} func (FocusCmd) ImplementsCommand() {}
func (SoftKeyboardCmd) ImplementsCommand() {} func (SoftKeyboardCmd) ImplementsCommand() {}
+1 -2
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build !darwin //go:build !darwin && !js
// +build !darwin
package key package key
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: Unlicense OR MIT
package key
import (
"strings"
"syscall/js"
)
// ModShortcut is the platform's shortcut modifier, usually the ctrl
// modifier. On Apple platforms it is the cmd key.
var ModShortcut = ModCtrl
// ModShortcut is the platform's alternative shortcut modifier,
// usually the ctrl modifier. On Apple platforms it is the alt modifier.
var ModShortcutAlt = ModCtrl
func init() {
nav := js.Global().Get("navigator")
if !nav.Truthy() {
return // Almost impossible to happen
}
platform := ""
if p := nav.Get("platform"); p.Truthy() {
platform = p.String()
}
platform = strings.ToLower(platform)
// Based on https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform#examples
for _, darwinPlatform := range []string{"mac", "iphone", "ipad", "ipod"} {
if strings.HasPrefix(platform, darwinPlatform) {
ModShortcut = ModCommand
ModShortcutAlt = ModAlt
return
}
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ type Event struct {
Source Source Source Source
// PointerID is the id for the pointer and can be used // PointerID is the id for the pointer and can be used
// to track a particular pointer from Press to // to track a particular pointer from Press to
// Release. // Release. Populated for Press, Release, Move, Drag,
// Enter, Leave, and Cancel; Scroll events are not
// bound to a tracked pointer and leave it zero.
PointerID ID PointerID ID
// Priority is the priority of the receiving handler // Priority is the priority of the receiving handler
// for this event. // for this event.
-1
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Unlicense OR MIT // SPDX-License-Identifier: Unlicense OR MIT
//go:build !race //go:build !race
// +build !race
package layout package layout
+14
View File
@@ -22,6 +22,8 @@ type Flex struct {
// size of Flexed children. If WeightSum is zero, the sum // size of Flexed children. If WeightSum is zero, the sum
// of all Flexed weights is used. // of all Flexed weights is used.
WeightSum float32 WeightSum float32
// Gap is the space in pixels between children.
Gap int
} }
// FlexChild is the descriptor for a Flex child. // FlexChild is the descriptor for a Flex child.
@@ -82,6 +84,14 @@ func (f Flex) Layout(gtx Context, children ...FlexChild) Dimensions {
mainMin, mainMax := f.Axis.mainConstraint(cs) mainMin, mainMax := f.Axis.mainConstraint(cs)
crossMin, crossMax := f.Axis.crossConstraint(cs) crossMin, crossMax := f.Axis.crossConstraint(cs)
remaining := mainMax remaining := mainMax
// Reserve space for gaps between children.
if len(children) > 1 && f.Gap > 0 {
totalGap := f.Gap * (len(children) - 1)
remaining -= totalGap
if remaining < 0 {
remaining = 0
}
}
var totalWeight float32 var totalWeight float32
cgtx := gtx cgtx := gtx
// Note: previously the scratch space was inside FlexChild. // Note: previously the scratch space was inside FlexChild.
@@ -162,6 +172,9 @@ func (f Flex) Layout(gtx Context, children ...FlexChild) Dimensions {
maxBaseline = b maxBaseline = b
} }
} }
if len(children) > 1 && f.Gap > 0 {
size += f.Gap * (len(children) - 1)
}
var space int var space int
if mainMin > size { if mainMin > size {
space = mainMin - size space = mainMin - size
@@ -199,6 +212,7 @@ func (f Flex) Layout(gtx Context, children ...FlexChild) Dimensions {
trans.Pop() trans.Pop()
mainSize += f.Axis.Convert(dims.Size).X mainSize += f.Axis.Convert(dims.Size).X
if i < len(children)-1 { if i < len(children)-1 {
mainSize += f.Gap
switch f.Spacing { switch f.Spacing {
case SpaceEvenly: case SpaceEvenly:
mainSize += space / (1 + len(children)) mainSize += space / (1 + len(children))
+100
View File
@@ -44,6 +44,106 @@ func TestFlex(t *testing.T) {
} }
} }
func TestFlexGap(t *testing.T) {
gtx := Context{
Ops: new(op.Ops),
Constraints: Constraints{
Max: image.Pt(100, 100),
},
}
// Two 20px children with 10px gap = 50px total.
dims := Flex{Gap: 10}.Layout(gtx,
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(20, 10)}
}),
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(20, 10)}
}),
)
if got, exp := dims.Size.X, 50; got != exp {
t.Errorf("two rigid children with gap: got width %d, expected %d", got, exp)
}
// Three children: gap added between each pair.
dims = Flex{Gap: 5}.Layout(gtx,
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(10, 10)}
}),
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(10, 10)}
}),
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(10, 10)}
}),
)
if got, exp := dims.Size.X, 40; got != exp {
t.Errorf("three rigid children with gap: got width %d, expected %d", got, exp)
}
// Single child: no gap added.
dims = Flex{Gap: 10}.Layout(gtx,
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(20, 10)}
}),
)
if got, exp := dims.Size.X, 20; got != exp {
t.Errorf("single child with gap: got width %d, expected %d", got, exp)
}
// Gap with flexed children: gap is reserved from available space.
dims = Flex{Gap: 10}.Layout(gtx,
Flexed(1, func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(gtx.Constraints.Max.X, 10)}
}),
Flexed(1, func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(gtx.Constraints.Max.X, 10)}
}),
)
// 100px max - 10px gap = 90px for flex; 45px each.
if got, exp := dims.Size.X, 100; got != exp {
t.Errorf("flexed children with gap: got width %d, expected %d", got, exp)
}
// Vertical axis with gap.
dims = Flex{Axis: Vertical, Gap: 15}.Layout(gtx,
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(10, 20)}
}),
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(10, 20)}
}),
)
if got, exp := dims.Size.Y, 55; got != exp {
t.Errorf("vertical with gap: got height %d, expected %d", got, exp)
}
}
func TestFlexGapConstraints(t *testing.T) {
gtx := Context{
Ops: new(op.Ops),
Constraints: Constraints{
Max: image.Pt(100, 100),
},
}
// Verify that flexed children receive constraints with gap accounted for.
var flexMax int
Flex{Gap: 10}.Layout(gtx,
Rigid(func(gtx Context) Dimensions {
return Dimensions{Size: image.Pt(30, 10)}
}),
Flexed(1, func(gtx Context) Dimensions {
flexMax = gtx.Constraints.Max.X
return Dimensions{Size: image.Pt(gtx.Constraints.Max.X, 10)}
}),
)
// 100 - 10 (gap) - 30 (rigid) = 60 remaining for flex.
if got, exp := flexMax, 60; got != exp {
t.Errorf("flex constraint with gap: got %d, expected %d", got, exp)
}
}
func TestDirection(t *testing.T) { func TestDirection(t *testing.T) {
max := image.Pt(100, 100) max := image.Pt(100, 100)
for _, tc := range []struct { for _, tc := range []struct {
+20 -7
View File
@@ -31,6 +31,8 @@ type List struct {
Alignment Alignment Alignment Alignment
// ScrollAnyAxis allows any scroll axis to scroll the list, not just the main axis. // ScrollAnyAxis allows any scroll axis to scroll the list, not just the main axis.
ScrollAnyAxis bool ScrollAnyAxis bool
// Gap is the space in pixels between children.
Gap int
cs Constraints cs Constraints
scroll gesture.Scroll scroll gesture.Scroll
@@ -130,7 +132,7 @@ func (l *List) Layout(gtx Context, len int, w ListElement) Dimensions {
} }
if numLaidOut > 0 { if numLaidOut > 0 {
l.Position.Length = laidOutTotalLength * len / numLaidOut l.Position.Length = laidOutTotalLength*len/numLaidOut + l.Gap*(len-1)
} else { } else {
l.Position.Length = 0 l.Position.Length = 0
} }
@@ -223,11 +225,11 @@ func (l *List) nextDir() iterationDir {
if len(l.children) > 0 { if len(l.children) > 0 {
if l.Position.First > 0 { if l.Position.First > 0 {
firstChild := l.children[0] firstChild := l.children[0]
firstSize = l.Axis.Convert(firstChild.size).X firstSize = l.Axis.Convert(firstChild.size).X + l.Gap
} }
if last < l.len { if last < l.len {
lastChild := l.children[len(l.children)-1] lastChild := l.children[len(l.children)-1]
lastSize = l.Axis.Convert(lastChild.size).X lastSize = l.Axis.Convert(lastChild.size).X + l.Gap
} }
} }
switch { switch {
@@ -245,6 +247,9 @@ func (l *List) nextDir() iterationDir {
func (l *List) end(dims Dimensions, call op.CallOp) { func (l *List) end(dims Dimensions, call op.CallOp) {
child := scrollChild{dims.Size, call} child := scrollChild{dims.Size, call}
mainSize := l.Axis.Convert(child.size).X mainSize := l.Axis.Convert(child.size).X
if len(l.children) > 0 {
l.maxSize += l.Gap
}
l.maxSize += mainSize l.maxSize += mainSize
switch l.dir { switch l.dir {
case iterateForward: case iterateForward:
@@ -254,7 +259,7 @@ func (l *List) end(dims Dimensions, call op.CallOp) {
copy(l.children[1:], l.children) copy(l.children[1:], l.children)
l.children[0] = child l.children[0] = child
l.Position.First-- l.Position.First--
l.Position.Offset += mainSize l.Position.Offset += mainSize + l.Gap
default: default:
panic("call Next before End") panic("call Next before End")
} }
@@ -279,7 +284,7 @@ func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions {
break break
} }
l.Position.First++ l.Position.First++
l.Position.Offset -= mainSize l.Position.Offset -= mainSize + l.Gap
first = child first = child
children = children[1:] children = children[1:]
} }
@@ -291,6 +296,9 @@ func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions {
if c := sz.Y; c > maxCross { if c := sz.Y; c > maxCross {
maxCross = c maxCross = c
} }
if i > 0 {
size += l.Gap
}
size += sz.X size += sz.X
if size >= mainMax { if size >= mainMax {
if i < len(children)-1 { if i < len(children)-1 {
@@ -326,14 +334,19 @@ func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions {
// Lay out leading invisible child. // Lay out leading invisible child.
if first != (scrollChild{}) { if first != (scrollChild{}) {
sz := l.Axis.Convert(first.size) sz := l.Axis.Convert(first.size)
pos -= sz.X pos -= sz.X + l.Gap
layout(first) layout(first)
pos += l.Gap
} }
for _, child := range children { for i, child := range children {
if i > 0 {
pos += l.Gap
}
layout(child) layout(child)
} }
// Lay out trailing invisible child. // Lay out trailing invisible child.
if last != (scrollChild{}) { if last != (scrollChild{}) {
pos += l.Gap
layout(last) layout(last)
} }
atStart := l.Position.First == 0 && l.Position.Offset <= 0 atStart := l.Position.First == 0 && l.Position.Offset <= 0
+87
View File
@@ -184,6 +184,93 @@ func TestListPosition(t *testing.T) {
} }
} }
func TestListGap(t *testing.T) {
gtx := Context{
Ops: new(op.Ops),
Constraints: Constraints{
Max: image.Pt(100, 20),
},
}
// Two 10px children with 5px gap: total 25px.
l := List{Gap: 5}
dims := l.Layout(gtx, 2, func(gtx Context, idx int) Dimensions {
return Dimensions{Size: image.Pt(10, 10)}
})
if got, exp := dims.Size.X, 25; got != exp {
t.Errorf("two children with gap: got width %d, expected %d", got, exp)
}
// Three 10px children with 5px gap: total 40px.
l = List{Gap: 5}
dims = l.Layout(gtx, 3, func(gtx Context, idx int) Dimensions {
return Dimensions{Size: image.Pt(10, 10)}
})
if got, exp := dims.Size.X, 40; got != exp {
t.Errorf("three children with gap: got width %d, expected %d", got, exp)
}
// Single child: no gap.
l = List{Gap: 5}
dims = l.Layout(gtx, 1, func(gtx Context, idx int) Dimensions {
return Dimensions{Size: image.Pt(10, 10)}
})
if got, exp := dims.Size.X, 10; got != exp {
t.Errorf("single child with gap: got width %d, expected %d", got, exp)
}
// Zero children: no gap.
l = List{Gap: 5}
dims = l.Layout(gtx, 0, nil)
if got, exp := dims.Size.X, 0; got != exp {
t.Errorf("no children with gap: got width %d, expected %d", got, exp)
}
}
func TestListGapVertical(t *testing.T) {
gtx := Context{
Ops: new(op.Ops),
Constraints: Constraints{
Max: image.Pt(20, 100),
},
}
l := List{Axis: Vertical, Gap: 10}
dims := l.Layout(gtx, 3, func(gtx Context, idx int) Dimensions {
return Dimensions{Size: image.Pt(10, 15)}
})
// 3*15 + 2*10 = 65.
if got, exp := dims.Size.Y, 65; got != exp {
t.Errorf("vertical list with gap: got height %d, expected %d", got, exp)
}
}
func TestListGapPosition(t *testing.T) {
gtx := Context{
Ops: new(op.Ops),
Constraints: Constraints{
Max: image.Pt(30, 20),
},
}
// Viewport 30px, 5 children of 10px with 5px gap.
// Children fill: 10, 10+5+10=25, 25+5+10=40 >= 30, so 3 visible (last partially).
l := List{Gap: 5}
l.Layout(gtx, 5, func(gtx Context, idx int) Dimensions {
return Dimensions{Size: image.Pt(10, 10)}
})
if got, exp := l.Position.Count, 3; got != exp {
t.Errorf("visible count with gap: got %d, expected %d", got, exp)
}
if got, exp := l.Position.First, 0; got != exp {
t.Errorf("first with gap: got %d, expected %d", got, exp)
}
// OffsetLast = mainMax - size = 30 - 40 = -10.
if got, exp := l.Position.OffsetLast, -10; got != exp {
t.Errorf("offset last with gap: got %d, expected %d", got, exp)
}
}
func TestExtraChildren(t *testing.T) { func TestExtraChildren(t *testing.T) {
var l List var l List
l.Position.First = 1 l.Position.First = 1
+1 -1
View File
@@ -224,7 +224,7 @@ func (p *parser) parse(rule string) ([]string, error) {
// //
// LIST ::= <FACE> <COMMA> <LIST> | <FACE> // LIST ::= <FACE> <COMMA> <LIST> | <FACE>
func (p *parser) parseList() error { func (p *parser) parseList() error {
if len(p.tokens) < 0 { if len(p.tokens) == 0 {
return fmt.Errorf("expected family name, got EOF") return fmt.Errorf("expected family name, got EOF")
} }
if head := p.tokens[0]; head.kind != tokenStr { if head := p.tokens[0]; head.kind != tokenStr {
+65 -60
View File
@@ -103,8 +103,7 @@ func (l *line) insertTrailingSyntheticNewline(newLineClusterIdx int) {
clusterIndex: newLineClusterIdx, clusterIndex: newLineClusterIdx,
glyphCount: 0, glyphCount: 0,
runeCount: 1, runeCount: 1,
xAdvance: 0, advance: 0,
yAdvance: 0,
xOffset: 0, xOffset: 0,
yOffset: 0, yOffset: 0,
} }
@@ -160,9 +159,9 @@ type glyph struct {
// runeCount is the quantity of runes in the source text that this glyph // runeCount is the quantity of runes in the source text that this glyph
// corresponds to. // corresponds to.
runeCount int runeCount int
// xAdvance and yAdvance describe the distance the dot moves when // advance is the distance the dot moves when laying out the glyph along
// laying out the glyph on the X or Y axis. // the run's primary axis.
xAdvance, yAdvance fixed.Int26_6 advance fixed.Int26_6
// xOffset and yOffset describe offsets from the dot that should be // xOffset and yOffset describe offsets from the dot that should be
// applied when rendering the glyph. // applied when rendering the glyph.
xOffset, yOffset fixed.Int26_6 xOffset, yOffset fixed.Int26_6
@@ -270,8 +269,9 @@ func newShaperImpl(systemFonts bool, collection []FontFace) *shaperImpl {
// in the order in which they are loaded, with the first face being the default. // in the order in which they are loaded, with the first face being the default.
func (s *shaperImpl) Load(f FontFace) { func (s *shaperImpl) Load(f FontFace) {
desc := opentype.FontToDescription(f.Font) desc := opentype.FontToDescription(f.Font)
s.fontMap.AddFace(f.Face.Face(), fontscan.Location{File: fmt.Sprint(desc)}, desc) face := f.Face.Face()
s.addFace(f.Face.Face(), f.Font) s.fontMap.AddFace(face, fontscan.Location{File: fmt.Sprint(desc)}, desc)
s.addFace(face, f.Font)
} }
func (s *shaperImpl) addFace(f *font.Face, md giofont.Font) { func (s *shaperImpl) addFace(f *font.Face, md giofont.Font) {
@@ -312,7 +312,7 @@ func splitByScript(inputs []shaping.Input, documentDir di.Direction, buf []shapi
r := input.Text[i] r := input.Text[i]
runeScript := language.LookupScript(r) runeScript := language.LookupScript(r)
if runeScript == language.Common || runeScript == currentInput.Script { if runeScript == language.Common || runeScript == language.Inherited || runeScript == currentInput.Script {
continue continue
} }
@@ -437,8 +437,7 @@ func (s *shaperImpl) shapeText(ppem fixed.Int26_6, lc system.Locale, txt []rune)
Height: input.Size, Height: input.Size,
XBearing: 0, XBearing: 0,
YBearing: 0, YBearing: 0,
XAdvance: input.Size, Advance: input.Size,
YAdvance: input.Size,
XOffset: 0, XOffset: 0,
YOffset: 0, YOffset: 0,
ClusterIndex: input.RunStart, ClusterIndex: input.RunStart,
@@ -656,53 +655,60 @@ func (s *shaperImpl) Shape(pathOps *op.Ops, gs []Glyph) clip.PathSpec {
} }
scaleFactor := fixedToFloat(ppem) / float32(face.Upem()) scaleFactor := fixedToFloat(ppem) / float32(face.Upem())
glyphData := face.GlyphData(gid) glyphData := face.GlyphData(gid)
var outline font.GlyphOutline
switch glyphData := glyphData.(type) { switch glyphData := glyphData.(type) {
case font.GlyphOutline: case font.GlyphOutline:
outline := glyphData outline = glyphData
// Move to glyph position. case font.GlyphSVG:
pos := f32.Point{ outline = glyphData.Outline
X: fixedToFloat((g.X - x) - g.Offset.X), default:
Y: -fixedToFloat(g.Offset.Y), continue
}
builder.Move(pos.Sub(lastPos))
lastPos = pos
var lastArg f32.Point
// Convert fonts.Segments to relative segments.
for _, fseg := range outline.Segments {
nargs := 1
switch fseg.Op {
case gotextot.SegmentOpQuadTo:
nargs = 2
case gotextot.SegmentOpCubeTo:
nargs = 3
}
var args [3]f32.Point
for i := range nargs {
a := f32.Point{
X: fseg.Args[i].X * scaleFactor,
Y: -fseg.Args[i].Y * scaleFactor,
}
args[i] = a.Sub(lastArg)
if i == nargs-1 {
lastArg = a
}
}
switch fseg.Op {
case gotextot.SegmentOpMoveTo:
builder.Move(args[0])
case gotextot.SegmentOpLineTo:
builder.Line(args[0])
case gotextot.SegmentOpQuadTo:
builder.Quad(args[0], args[1])
case gotextot.SegmentOpCubeTo:
builder.Cube(args[0], args[1], args[2])
default:
panic("unsupported segment op")
}
}
lastPos = lastPos.Add(lastArg)
} }
// Move to glyph position.
pos := f32.Point{
X: fixedToFloat((g.X - x) - g.Offset.X),
Y: -fixedToFloat(g.Offset.Y),
}
builder.Move(pos.Sub(lastPos))
lastPos = pos
var lastArg f32.Point
// Convert fonts.Segments to relative segments.
for _, fseg := range outline.Segments {
nargs := 1
switch fseg.Op {
case gotextot.SegmentOpQuadTo:
nargs = 2
case gotextot.SegmentOpCubeTo:
nargs = 3
}
var args [3]f32.Point
for i := range nargs {
a := f32.Point{
X: fseg.Args[i].X * scaleFactor,
Y: -fseg.Args[i].Y * scaleFactor,
}
args[i] = a.Sub(lastArg)
if i == nargs-1 {
lastArg = a
}
}
switch fseg.Op {
case gotextot.SegmentOpMoveTo:
builder.Move(args[0])
case gotextot.SegmentOpLineTo:
builder.Line(args[0])
case gotextot.SegmentOpQuadTo:
builder.Quad(args[0], args[1])
case gotextot.SegmentOpCubeTo:
builder.Cube(args[0], args[1], args[2])
default:
panic("unsupported segment op")
}
}
lastPos = lastPos.Add(lastArg)
} }
return builder.End() return builder.End()
} }
@@ -761,7 +767,7 @@ func (s *shaperImpl) Bitmaps(ops *op.Ops, gs []Glyph) op.CallOp {
imgSize = bitmapData.size imgSize = bitmapData.size
} }
off := op.Affine(f32.AffineId().Offset(f32.Point{ off := op.Affine(f32.AffineId().Offset(f32.Point{
X: fixedToFloat((g.X - x) - g.Offset.X), X: fixedToFloat((g.X - x) + g.Offset.X),
Y: fixedToFloat(g.Offset.Y + g.Bounds.Min.Y), Y: fixedToFloat(g.Offset.Y + g.Bounds.Min.Y),
})).Push(ops) })).Push(ops)
cl := clip.Rect{Max: imgSize}.Push(ops) cl := clip.Rect{Max: imgSize}.Push(ops)
@@ -847,11 +853,10 @@ func toGioGlyphs(in []shaping.Glyph, ppem fixed.Int26_6, faceIdx int) []glyph {
bounds.Max = bounds.Min.Add(fixed.Point26_6{X: g.Width, Y: -g.Height}) bounds.Max = bounds.Min.Add(fixed.Point26_6{X: g.Width, Y: -g.Height})
out = append(out, glyph{ out = append(out, glyph{
id: newGlyphID(ppem, faceIdx, g.GlyphID), id: newGlyphID(ppem, faceIdx, g.GlyphID),
clusterIndex: g.ClusterIndex, clusterIndex: g.TextIndex(),
runeCount: g.RuneCount, runeCount: g.RunesCount(),
glyphCount: g.GlyphCount, glyphCount: g.GlyphsCount(),
xAdvance: g.XAdvance, advance: g.Advance,
yAdvance: g.YAdvance,
xOffset: g.XOffset, xOffset: g.XOffset,
yOffset: g.YOffset, yOffset: g.YOffset,
bounds: bounds, bounds: bounds,
+102
View File
@@ -8,7 +8,9 @@ import (
"testing" "testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular" nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"github.com/go-text/typesetting/di"
"github.com/go-text/typesetting/font" "github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/language"
"github.com/go-text/typesetting/shaping" "github.com/go-text/typesetting/shaping"
"golang.org/x/image/font/gofont/goregular" "golang.org/x/image/font/gofont/goregular"
"golang.org/x/image/math/fixed" "golang.org/x/image/math/fixed"
@@ -593,3 +595,103 @@ func TestGlyphIDPacking(t *testing.T) {
}) })
} }
} }
// TestArabicDiacriticClustering verifies that Arabic diacritics (which usually have
// script 'Inherited') are correctly clustered with their base Arabic letters,
// rather than being split into a separate shaping run.
func TestArabicDiacriticClustering(t *testing.T) {
tests := []struct {
name string
input []rune
wantRuns int
wantScript language.Script
wantDirection di.Direction
}{
{
name: "Arabic Letter + Diacritic",
// \u0628 => BEH
// \u0650 => KASRA (Diacritic)
input: []rune{'\u0628', '\u0650'},
wantRuns: 1,
wantScript: language.Arabic,
wantDirection: di.DirectionRTL,
},
{
name: "Arabic Word with Multiple Diacritics",
input: []rune{
'\u0628', // BEH
'\u0650', // KASRA
'\u0633', // SEEN
'\u0652', // SUKUN
'\u0645', // MEEM
'\u0650', // KASRA
},
wantRuns: 1,
wantScript: language.Arabic,
wantDirection: di.DirectionRTL,
},
{
name: "Mixed Script (CONTROL Case) #1",
// Arabic Letter + Latin Letter
// THESE MUST SPLIT TO 2.
input: []rune{'\u0628', 'A'},
wantRuns: 2,
wantScript: language.Arabic,
wantDirection: di.DirectionRTL,
},
{
name: "Mixed Script (CONTROL Case) #2",
// Arabic Letter + Diacritic + Diacritic + Latin Letter + Arabic Letter + Diacritic
// THESE MUST SPLIT TO 3.
input: []rune{'\u0628', '\u0651', '\u0650', 'A', '\u0628', '\u0650'},
wantRuns: 3,
wantScript: language.Arabic,
wantDirection: di.DirectionRTL,
},
{
name: "Mixed Script (A little 'stress' test)",
// Latin 's' + Arabic Kasra + Latin 'r' + Arabic Fatha
// this technically valid unicode!
// the diacritics should inherit "Latin"
input: []rune{'s', '\u0651', '\u0650', 'r', '\u064E'},
wantRuns: 1,
wantScript: language.Latin,
wantDirection: di.DirectionLTR,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inputs := []shaping.Input{{
Text: tt.input,
RunStart: 0,
RunEnd: len(tt.input),
Direction: tt.wantDirection,
Script: language.Arabic,
Face: nil, // face doesn't really matter for splitting anyway
Size: fixed.I(10),
}}
got := splitByScript(inputs, tt.wantDirection, nil)
if len(got) != tt.wantRuns {
t.Fatalf("splitByScript produced %d runs, expected %d. \nRun details: %+v", len(got), tt.wantRuns, got)
}
// this is for the single-run cases
// we need to verify the integrity of the single run
// to ensure
// - the truncation didn't happen early on (when first hitting a diacritic)
// - and the right dominant script label was used
if tt.wantRuns == 1 {
run := got[0]
if run.RunEnd != len(tt.input) {
t.Errorf("Run truncated early. End = %d, expected %d", run.RunEnd, len(tt.input))
}
if run.Script != tt.wantScript {
t.Errorf("Run assigned wrong script. Got %s, expected %s", run.Script, tt.wantScript)
}
}
})
}
}
+3 -7
View File
@@ -259,10 +259,6 @@ func WithCollection(collection []FontFace) ShaperOption {
} }
// NewShaper constructs a shaper with the provided options. // NewShaper constructs a shaper with the provided options.
//
// NewShaper must be called after [app.NewWindow], unless the [NoSystemFonts]
// option is specified. This is an unfortunate restriction caused by some platforms
// such as Android.
func NewShaper(options ...ShaperOption) *Shaper { func NewShaper(options ...ShaperOption) *Shaper {
l := &Shaper{} l := &Shaper{}
for _, opt := range options { for _, opt := range options {
@@ -468,7 +464,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
if rtl { if rtl {
// Modify the advance prior to computing runOffset to ensure that the // Modify the advance prior to computing runOffset to ensure that the
// current glyph's width is subtracted in RTL. // current glyph's width is subtracted in RTL.
l.advance += g.xAdvance l.advance += g.advance
} }
// runOffset computes how far into the run the dot should be positioned. // runOffset computes how far into the run the dot should be positioned.
runOffset := l.advance runOffset := l.advance
@@ -481,7 +477,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
Y: int32(line.yOffset), Y: int32(line.yOffset),
Ascent: line.ascent, Ascent: line.ascent,
Descent: line.descent, Descent: line.descent,
Advance: g.xAdvance, Advance: g.advance,
Runes: uint16(g.runeCount), Runes: uint16(g.runeCount),
Offset: fixed.Point26_6{ Offset: fixed.Point26_6{
X: g.xOffset, X: g.xOffset,
@@ -494,7 +490,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
} }
l.glyph++ l.glyph++
if !rtl { if !rtl {
l.advance += g.xAdvance l.advance += g.advance
} }
endOfRun := l.glyph == len(run.Glyphs) endOfRun := l.glyph == len(run.Glyphs)
+2 -2
View File
@@ -450,8 +450,8 @@ func printLinePositioning(t *testing.T, lines []line, glyphs []Glyph) {
for g := start; ; g += inc { for g := start; ; g += inc {
glyph := run.Glyphs[g] glyph := run.Glyphs[g]
if glyphCursor < len(glyphs) { if glyphCursor < len(glyphs) {
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags) t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.advance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount) t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.advance, glyph.runeCount, glyph.glyphCount)
} }
glyphCursor++ glyphCursor++
if g == end { if g == end {
+30 -2
View File
@@ -25,6 +25,7 @@ import (
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text" "gioui.org/text"
"gioui.org/unit" "gioui.org/unit"
) )
@@ -107,8 +108,9 @@ type imeState struct {
rng key.Range rng key.Range
caret key.Caret caret key.Caret
} }
snippet key.Snippet snippet key.Snippet
start, end int composition key.Range
start, end int
} }
type maskReader struct { type maskReader struct {
@@ -398,9 +400,12 @@ func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) {
case key.FocusEvent: case key.FocusEvent:
// Reset IME state. // Reset IME state.
e.ime.imeState = imeState{} e.ime.imeState = imeState{}
e.ime.composition = key.Range{Start: -1, End: -1}
if ke.Focus && !e.ReadOnly { if ke.Focus && !e.ReadOnly {
gtx.Execute(key.SoftKeyboardCmd{Show: true}) gtx.Execute(key.SoftKeyboardCmd{Show: true})
} }
case key.CompositionEvent:
e.ime.composition = key.Range(ke)
case key.Event: case key.Event:
if !gtx.Focused(e) || ke.State != key.Press { if !gtx.Focused(e) || ke.State != key.Press {
break break
@@ -735,6 +740,7 @@ func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.Call
if e.Len() > 0 { if e.Len() > 0 {
e.paintSelection(gtx, selectMaterial) e.paintSelection(gtx, selectMaterial)
e.paintText(gtx, textMaterial) e.paintText(gtx, textMaterial)
e.paintComposition(gtx, textMaterial)
} }
if gtx.Enabled() { if gtx.Enabled() {
e.paintCaret(gtx, textMaterial) e.paintCaret(gtx, textMaterial)
@@ -759,6 +765,28 @@ func (e *Editor) paintText(gtx layout.Context, material op.CallOp) {
e.text.PaintText(gtx, material) e.text.PaintText(gtx, material)
} }
func (e *Editor) paintComposition(gtx layout.Context, material op.CallOp) {
e.initBuffer()
r := e.ime.composition
if r.Start == -1 || r.Start == r.End {
return
}
e.text.regions = e.text.Regions(r.Start, r.End, e.text.regions)
thickness := max(gtx.Dp(unit.Dp(1)), 1)
for _, region := range e.text.regions {
y := region.Bounds.Max.Y - max(region.Baseline/3, thickness)
underline := image.Rect(region.Bounds.Min.X, y, region.Bounds.Max.X, y+thickness)
underline = underline.Intersect(image.Rectangle{Max: e.text.viewSize})
if underline.Empty() {
continue
}
stack := clip.Rect(underline).Push(gtx.Ops)
material.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
stack.Pop()
}
}
// paintCaret paints the text glyphs using the provided material to set the fill material // paintCaret paints the text glyphs using the provided material to set the fill material
// of the caret rectangle. // of the caret rectangle.
func (e *Editor) paintCaret(gtx layout.Context, material op.CallOp) { func (e *Editor) paintCaret(gtx layout.Context, material op.CallOp) {
+53
View File
@@ -1042,6 +1042,59 @@ func TestEditorSelectShortcuts(t *testing.T) {
} }
} }
func TestEditorSelect(t *testing.T) {
tFont := font.Font{}
tFontSize := unit.Sp(10)
tShaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
tEditor := &Editor{
SingleLine: false,
ReadOnly: true,
}
lines := "abc abc abc def def def ghi ghi ghi"
tEditor.SetText(lines)
tRouter := new(input.Router)
gtx := layout.Context{
Ops: new(op.Ops),
Locale: english,
Constraints: layout.Exact(image.Pt(50, 100)),
Source: tRouter.Source(),
}
gtx.Execute(key.FocusCmd{Tag: tEditor})
tEditor.Layout(gtx, tShaper, tFont, tFontSize, op.CallOp{}, op.CallOp{})
index := tEditor.text.index
firstLineGlyphs := index.lines[0].glyphs
firstLine := lines[:firstLineGlyphs]
yOffFirstLineCenter := index.lines[0].yOff / 2
tEditor.ClearSelection()
tEditor.text.MoveCoord(image.Pt(100, yOffFirstLineCenter))
if cStart, cEnd := tEditor.Selection(); cStart != len(firstLine) || cEnd != 0 {
t.Errorf("TestEditorSelect %d: initial selection", len(firstLine))
}
if got := tEditor.SelectedText(); got != firstLine {
t.Errorf("TestEditorSelect : Expected %q, got %q", firstLine, got)
}
yOffSecondLineCenter := (index.lines[1].yOff-index.lines[0].yOff)/2 + index.lines[0].yOff
tEditor.text.MoveCoord(image.Pt(100, yOffSecondLineCenter))
secondLineGlyphs := index.lines[1].glyphs
firstTwoLines := lines[:firstLineGlyphs+secondLineGlyphs]
if got := tEditor.SelectedText(); got != firstTwoLines {
t.Errorf("TestEditorSelect : Expected %q, got %q", firstTwoLines, got)
}
tEditor.Layout(gtx, tShaper, tFont, tFontSize, op.CallOp{}, op.CallOp{})
firstLineEnd := index.lines[0].width.Round()
firstRegionMaxWidth := tEditor.text.regions[0].Bounds.Max.X
if firstRegionMaxWidth != firstLineEnd {
t.Errorf("Selection paint should contain last character")
}
}
// Verify that an existing selection is dismissed when you press arrow keys. // Verify that an existing selection is dismissed when you press arrow keys.
func TestSelectMove(t *testing.T) { func TestSelectMove(t *testing.T) {
e := new(Editor) e := new(Editor)
+54 -27
View File
@@ -22,6 +22,10 @@ type lineInfo struct {
glyphs int glyphs int
} }
func (l lineInfo) getLineEnd() fixed.Int26_6 {
return l.xOff + l.width
}
type glyphIndex struct { type glyphIndex struct {
// glyphs holds the glyphs processed. // glyphs holds the glyphs processed.
glyphs []text.Glyph glyphs []text.Glyph
@@ -231,46 +235,61 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
} }
func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) { func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) {
if len(g.positions) == 0 { n := len(g.positions)
if n == 0 {
return combinedPos{}, 0 return combinedPos{}, 0
} }
i := sort.Search(len(g.positions), func(i int) bool { i := sort.Search(n, func(i int) bool {
pos := g.positions[i] pos := g.positions[i]
return pos.runes >= runeIdx return pos.runes >= runeIdx
}) })
if i > 0 {
i-- notFound := i == n
if notFound {
return g.positions[n-1], n - 1
} }
closest := g.positions[i] return g.positions[i], i
closestI := i
for ; i < len(g.positions); i++ {
if g.positions[i].runes == runeIdx {
return g.positions[i], i
}
}
return closest, closestI
} }
func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos { func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos {
if len(g.positions) == 0 { n := len(g.positions)
if n == 0 {
return combinedPos{} return combinedPos{}
} }
i := sort.Search(len(g.positions), func(i int) bool { i := sort.Search(n, func(i int) bool {
pos := g.positions[i] pos := g.positions[i]
return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col) return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col)
}) })
if i > 0 { notFound := i == n
i-- if notFound {
return g.positions[n-1]
} }
prior := g.positions[i] pos := g.positions[i]
if i+1 >= len(g.positions) { foundInNextLine := pos.lineCol.line > lineCol.line
if foundInNextLine && i > 0 {
prior := g.positions[i-1]
prior.x = g.lines[lineCol.line].getLineEnd()
return prior return prior
} }
next := g.positions[i+1] return pos
if next.lineCol != lineCol { }
return prior
func (g *glyphIndex) atStartOfLine(pos combinedPos) bool {
if pos.runes == 0 {
return true
} }
return next prevRuneIndex := pos.runes - 1
lineOfPrevRune := g.positions[prevRuneIndex].lineCol.line
return lineOfPrevRune < pos.lineCol.line
}
func (g *glyphIndex) atEndOfLine(pos combinedPos) bool {
if pos.runes == g.positions[len(g.positions)-1].runes {
return true
}
next := pos.runes + 1
hasNext := next < len(g.positions)
return hasNext && g.positions[next].lineCol.line > pos.lineCol.line
} }
func dist(a, b fixed.Int26_6) fixed.Int26_6 { func dist(a, b fixed.Int26_6) fixed.Int26_6 {
@@ -280,9 +299,9 @@ func dist(a, b fixed.Int26_6) fixed.Int26_6 {
return b - a return b - a
} }
func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos { func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) (pos combinedPos, atEndOfLine bool) {
if len(g.positions) == 0 { if len(g.positions) == 0 {
return combinedPos{} return combinedPos{}, false
} }
i := sort.Search(len(g.positions), func(i int) bool { i := sort.Search(len(g.positions), func(i int) bool {
pos := g.positions[i] pos := g.positions[i]
@@ -292,7 +311,7 @@ func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos {
// short. Return either the last position or (if there are no // short. Return either the last position or (if there are no
// positions) the zero position. // positions) the zero position.
if i == len(g.positions) { if i == len(g.positions) {
return g.positions[i-1] return g.positions[i-1], false
} }
first := g.positions[i] first := g.positions[i]
// Find the best X coordinate. // Find the best X coordinate.
@@ -308,14 +327,22 @@ func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) combinedPos {
distance := dist(candidate.x, x) distance := dist(candidate.x, x)
// If we are *really* close to the current position candidate, just choose it. // If we are *really* close to the current position candidate, just choose it.
if distance.Round() == 0 { if distance.Round() == 0 {
return g.positions[i] return g.positions[i], false
} }
if distance < closestDist { if distance < closestDist {
closestDist = distance closestDist = distance
closest = i closest = i
} }
} }
return g.positions[closest] next := closest + 1
hasNext := next < len(g.positions)
if hasNext && g.atEndOfLine(g.positions[closest]) {
distance := dist(g.lines[line].getLineEnd(), x)
if distance < closestDist {
return g.positions[next], true
}
}
return g.positions[closest], false
} }
// makeRegion creates a text-aligned rectangle from start to end. The vertical // makeRegion creates a text-aligned rectangle from start to end. The vertical
+4 -16
View File
@@ -5,7 +5,6 @@ import (
"image/color" "image/color"
"gioui.org/f32" "gioui.org/f32"
"gioui.org/io/semantic"
"gioui.org/io/system" "gioui.org/io/system"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
@@ -86,21 +85,10 @@ func (d DecorationsStyle) layoutDecorations(gtx layout.Context) layout.Dimension
continue continue
} }
cl := d.Decorations.Clickable(a) cl := d.Decorations.Clickable(a)
dims := cl.Layout(gtx, func(gtx layout.Context) layout.Dimensions { dims := Clickable(gtx, cl, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops) system.ActionInputOp(a).Add(gtx.Ops)
return layout.Background{}.Layout(gtx, paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
func(gtx layout.Context) layout.Dimensions { return inset.Layout(gtx, w)
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
for _, c := range cl.History() {
drawInk(gtx, c)
}
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
return inset.Layout(gtx, w)
},
)
}) })
size.X += dims.Size.X size.X += dims.Size.X
if size.Y < dims.Size.Y { if size.Y < dims.Size.Y {
+7
View File
@@ -321,3 +321,10 @@ func (l ListStyle) Layout(gtx layout.Context, length int, w layout.ListElement)
return listDims return listDims
} }
// LayoutWidgets the widgets and its scrollbar.
func (l ListStyle) LayoutWidgets(gtx layout.Context, widgets ...layout.Widget) layout.Dimensions {
return l.Layout(gtx, len(widgets), func(gtx layout.Context, index int) layout.Dimensions {
return widgets[index](gtx)
})
}
+17 -16
View File
@@ -173,14 +173,17 @@ func (e *textView) closestToLineCol(line, col int) combinedPos {
return e.index.closestToLineCol(screenPos{line: line, col: col}) return e.index.closestToLineCol(screenPos{line: line, col: col})
} }
func (e *textView) closestToXY(x fixed.Int26_6, y int) combinedPos { func (e *textView) closestToXY(x fixed.Int26_6, y int) (combinedPos, bool) {
e.makeValid() e.makeValid()
return e.index.closestToXY(x, y) return e.index.closestToXY(x, y)
} }
func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) combinedPos { func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) (combinedPos, bool) {
// Find the closest existing rune position to the provided coordinates. // Find the closest existing rune position to the provided coordinates.
pos := e.closestToXY(x, y) pos, atEndOfLine := e.closestToXY(x, y)
if atEndOfLine {
return pos, true
}
// Resolve cluster boundaries on either side of the rune position. // Resolve cluster boundaries on either side of the rune position.
firstOption := e.moveByGraphemes(pos.runes, 0) firstOption := e.moveByGraphemes(pos.runes, 0)
distance := 1 distance := 1
@@ -194,9 +197,9 @@ func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) combinedPos {
second := e.closestToRune(secondOption) second := e.closestToRune(secondOption)
secondDist := absFixed(second.x - x) secondDist := absFixed(second.x - x)
if firstDist > secondDist { if firstDist > secondDist {
return second return second, false
} else { } else {
return first return first, false
} }
} }
@@ -214,8 +217,11 @@ func (e *textView) MoveLines(distance int, selAct selectionAction) {
x := caretStart.x + e.caret.xoff x := caretStart.x + e.caret.xoff
// Seek to line. // Seek to line.
pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0) pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0)
pos = e.closestToXYGraphemes(x, pos.y) pos, atEndOfLine := e.closestToXYGraphemes(x, pos.y)
e.caret.start = pos.runes e.caret.start = pos.runes
if atEndOfLine && pos.runes > 0 {
e.caret.start = pos.runes - 1
}
e.caret.xoff = x - pos.x e.caret.xoff = x - pos.x
e.updateSelection(selAct) e.updateSelection(selAct)
} }
@@ -356,10 +362,7 @@ func (e *textView) PaintText(gtx layout.Context, material op.CallOp) {
// caretWidth returns the width occupied by the caret for the current // caretWidth returns the width occupied by the caret for the current
// gtx. // gtx.
func (e *textView) caretWidth(gtx layout.Context) int { func (e *textView) caretWidth(gtx layout.Context) int {
carWidth2 := gtx.Dp(1) / 2 carWidth2 := max(gtx.Dp(1)/2, 1)
if carWidth2 < 1 {
carWidth2 = 1
}
return carWidth2 return carWidth2
} }
@@ -428,10 +431,7 @@ func (e *textView) ScrollBounds() image.Rectangle {
if e.SingleLine { if e.SingleLine {
if len(e.index.lines) > 0 { if len(e.index.lines) > 0 {
line := e.index.lines[0] line := e.index.lines[0]
b.Min.X = line.xOff.Floor() b.Min.X = min(line.xOff.Floor(), 0)
if b.Min.X > 0 {
b.Min.X = 0
}
} }
b.Max.X = e.dims.Size.X + b.Min.X - e.viewSize.X b.Max.X = e.dims.Size.X + b.Min.X - e.viewSize.X
} else { } else {
@@ -472,7 +472,8 @@ func (e *textView) scrollAbs(x, y int) {
func (e *textView) MoveCoord(pos image.Point) { func (e *textView) MoveCoord(pos image.Point) {
x := fixed.I(pos.X + e.scrollOff.X) x := fixed.I(pos.X + e.scrollOff.X)
y := pos.Y + e.scrollOff.Y y := pos.Y + e.scrollOff.Y
e.caret.start = e.closestToXYGraphemes(x, y).runes p, _ := e.closestToXYGraphemes(x, y)
e.caret.start = p.runes
e.caret.xoff = 0 e.caret.xoff = 0
} }
@@ -610,7 +611,7 @@ func (e *textView) MovePages(pages int, selAct selectionAction) {
caret := e.closestToRune(e.caret.start) caret := e.closestToRune(e.caret.start)
x := caret.x + e.caret.xoff x := caret.x + e.caret.xoff
y := caret.y + pages*e.viewSize.Y y := caret.y + pages*e.viewSize.Y
pos := e.closestToXYGraphemes(x, y) pos, _ := e.closestToXYGraphemes(x, y)
e.caret.start = pos.runes e.caret.start = pos.runes
e.caret.xoff = x - pos.x e.caret.xoff = x - pos.x
e.updateSelection(selAct) e.updateSelection(selAct)