Compare commits

...

32 Commits

Author SHA1 Message Date
Joe Julian d9b0c8c1c5 app: avoid relocking contexts every frame
Signed-off-by: Joe Julian <me@joejulian.name>
2026-04-22 19:08:26 -07:00
Joe Julian 7bb7a1407f app: lock explicitly before refreshing contexts
Signed-off-by: Joe Julian <me@joejulian.name>
2026-04-21 06:31:35 -07: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
49 changed files with 1534 additions and 258 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
-4
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
@@ -116,9 +115,6 @@ func (c *context) Unlock() {
} }
func (c *context) Refresh() error { func (c *context) Refresh() error {
if C.gio_makeCurrent(c.ctx) == 0 {
return errors.New("[EAGLContext setCurrentContext] failed")
}
if !c.init { if !c.init {
c.init = true c.init = true
c.frameBuffer = c.c.CreateFramebuffer() c.frameBuffer = c.c.CreateFramebuffer()
-3
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
@@ -112,8 +111,6 @@ func (c *glContext) Unlock() {
} }
func (c *glContext) Refresh() error { func (c *glContext) Refresh() error {
c.Lock()
defer c.Unlock()
C.gio_updateContext(c.ctx) C.gio_updateContext(c.ctx)
return nil return nil
} }
-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 (
+37 -2
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 (
@@ -315,6 +321,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 +426,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 +460,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 +513,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 +588,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)))
@@ -767,7 +791,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 +910,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
+5 -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.
@@ -163,6 +165,8 @@ type frameEvent struct {
Sync bool Sync bool
} }
// The caller must hold the context lock while using API, Refresh,
// RenderTarget, or Present.
type context interface { type context interface {
API() gpu.API API() gpu.API
RenderTarget() (gpu.RenderTarget, error) RenderTarget() (gpu.RenderTarget, error)
+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
+240 -4
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 {
@@ -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)
@@ -1052,3 +1076,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
+55 -13
View File
@@ -46,6 +46,10 @@ type Window struct {
ctx context ctx context
gpu gpu.GPU gpu gpu.GPU
// ctxNeedsLock tracks whether the rendering context must be made
// current again before the next GPU operation. Refresh paths, surface
// loss, and explicit unlocks all invalidate the current binding.
ctxNeedsLock bool
// timer tracks the delayed invalidate goroutine. // timer tracks the delayed invalidate goroutine.
timer struct { timer struct {
// quit is shuts down the goroutine. // quit is shuts down the goroutine.
@@ -142,16 +146,18 @@ 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
} }
w.ctxNeedsLock = true
sync = true sync = true
} }
} }
if err := w.lockContext(); err != nil {
w.destroyGPU()
return err
}
if sync && w.ctx != nil { if sync && w.ctx != nil {
if err := w.ctx.Refresh(); err != nil { if err := w.ctx.Refresh(); err != nil {
if errors.Is(err, errOutOfDate) { if errors.Is(err, errOutOfDate) {
@@ -165,11 +171,16 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
} }
return err return err
} }
w.unlockContext()
if err := w.lockContext(); 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 {
w.ctx.Unlock() w.unlockContext()
w.destroyGPU() w.destroyGPU()
return err return err
} }
@@ -177,7 +188,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
} }
if w.gpu != nil { if w.gpu != nil {
if err := w.frame(frame, size); err != nil { if err := w.frame(frame, size); err != nil {
w.ctx.Unlock() w.unlockContext()
if errors.Is(err, errOutOfDate) { if errors.Is(err, errOutOfDate) {
// GPU surface needs refreshing. // GPU surface needs refreshing.
sync = true sync = true
@@ -499,16 +510,37 @@ func (c *callbacks) ActionAt(p f32.Point) (system.Action, bool) {
return c.w.queue.ActionAt(p) return c.w.queue.ActionAt(p)
} }
func (w *Window) lockContext() error {
if w.ctx == nil || !w.ctxNeedsLock {
return nil
}
if err := w.ctx.Lock(); err != nil {
return err
}
w.ctxNeedsLock = false
return nil
}
func (w *Window) unlockContext() {
if w.ctx == nil || w.ctxNeedsLock {
return
}
w.ctx.Unlock()
w.ctxNeedsLock = true
}
func (w *Window) destroyGPU() { func (w *Window) destroyGPU() {
if w.gpu != nil { if w.gpu != nil {
w.ctx.Lock() if err := w.lockContext(); err == nil {
w.gpu.Release() w.gpu.Release()
w.ctx.Unlock() w.unlockContext()
}
w.gpu = nil w.gpu = nil
} }
if w.ctx != nil { if w.ctx != nil {
w.ctx.Release() w.ctx.Release()
w.ctx = nil w.ctx = nil
w.ctxNeedsLock = false
} }
} }
@@ -651,10 +683,11 @@ func (w *Window) processEvent(e event.Event) bool {
w.coalesced.destroy = &e2 w.coalesced.destroy = &e2
case ViewEvent: case ViewEvent:
if !e2.Valid() && w.gpu != nil { if !e2.Valid() && w.gpu != nil {
w.ctx.Lock() if err := w.lockContext(); err == nil {
w.gpu.Release() w.gpu.Release()
w.unlockContext()
}
w.gpu = nil w.gpu = nil
w.ctx.Unlock()
} }
w.coalesced.view = &e2 w.coalesced.view = &e2
case ConfigEvent: case ConfigEvent:
@@ -965,6 +998,15 @@ func Decorated(enabled bool) Option {
} }
} }
// TopMost windows will be rendered above all other non-top-most windows.
//
// TopMost windows are only supported on MacOS currently.
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.
+5 -3
View File
@@ -1,6 +1,6 @@
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
@@ -9,6 +9,8 @@ require (
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
+6 -4
View File
@@ -13,7 +13,9 @@ golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfa
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)
-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)
+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
}
}
}
-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 {
+53 -46
View File
@@ -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
} }
@@ -656,53 +656,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 +768,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)
+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)
}
}
})
}
}
+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
+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)