mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-01 07:35:40 +00:00
Compare commits
41 Commits
v0.9.0
...
0bf9626a58
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bf9626a58 | |||
| 15335a2b37 | |||
| acf5635575 | |||
| caccb608a5 | |||
| d52632b475 | |||
| dec57aea1c | |||
| e2e2c1a046 | |||
| e8c1e1ba11 | |||
| b1cadbdd76 | |||
| 451b7d3a74 | |||
| e49c5b02c7 | |||
| dfe4ff0200 | |||
| 65d86895b8 | |||
| c3a6e85f5c | |||
| 8c2e45b8f8 | |||
| 47ab4c97b2 | |||
| 760369174d | |||
| 92fa23b59b | |||
| f98baf7f76 | |||
| a6da4083de | |||
| bbb54d5f54 | |||
| 8b96643490 | |||
| 4ed9695d57 | |||
| 9b38545fc2 | |||
| 0d08eaa55c | |||
| 9ab8095d1a | |||
| 3d6cafa94d | |||
| 9966e922f9 | |||
| 3af0ebb3a8 | |||
| 99647591f6 | |||
| e38f80adc6 | |||
| 93419a77bd | |||
| c250d7d562 | |||
| 6e5bbfe8d4 | |||
| 42bc707f7c | |||
| 7bcb315ee1 | |||
| 74671a7f9e | |||
| f48cc2c47f | |||
| 818061a18a | |||
| 45963441c1 | |||
| be8d9df848 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Unlicense OR MIT
|
||||
image: debian/testing
|
||||
image: debian/stable
|
||||
packages:
|
||||
- clang
|
||||
- cmake
|
||||
|
||||
+8
-2
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Unlicense OR MIT
|
||||
image: debian/testing
|
||||
image: debian/stable
|
||||
packages:
|
||||
- curl
|
||||
- pkg-config
|
||||
@@ -18,6 +18,12 @@ packages:
|
||||
- libxinerama-dev
|
||||
- libxi-dev
|
||||
- libxxf86vm-dev
|
||||
- libegl-mesa0
|
||||
- libglx-mesa0
|
||||
- libgl1-mesa-dri
|
||||
- mesa-libgallium
|
||||
- libgbm1
|
||||
- libegl1
|
||||
- mesa-vulkan-drivers
|
||||
- wine
|
||||
- xvfb
|
||||
@@ -60,7 +66,7 @@ tasks:
|
||||
- add_32bit_arch: |
|
||||
sudo dpkg --add-architecture i386
|
||||
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: |
|
||||
cd gio
|
||||
go test -race ./...
|
||||
|
||||
@@ -4,6 +4,7 @@ package org.gioui;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.View;
|
||||
@@ -29,6 +30,7 @@ public final class GioActivity extends Activity {
|
||||
|
||||
layer.addView(view);
|
||||
setContentView(layer);
|
||||
onNewIntent(this.getIntent());
|
||||
}
|
||||
|
||||
@Override public void onDestroy() {
|
||||
@@ -46,6 +48,16 @@ public final class GioActivity extends Activity {
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override public void onPause() {
|
||||
super.onPause();
|
||||
view.pause();
|
||||
}
|
||||
|
||||
@Override public void onResume() {
|
||||
super.onResume();
|
||||
view.resume();
|
||||
}
|
||||
|
||||
@Override public void onConfigurationChanged(Configuration c) {
|
||||
super.onConfigurationChanged(c);
|
||||
view.configurationChanged();
|
||||
@@ -60,4 +72,9 @@ public final class GioActivity extends Activity {
|
||||
if (!view.backPressed())
|
||||
super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
view.onIntentEvent(intent);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-7
@@ -12,6 +12,7 @@ import android.app.Fragment;
|
||||
import android.app.FragmentManager;
|
||||
import android.app.FragmentTransaction;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Matrix;
|
||||
@@ -61,7 +62,6 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal
|
||||
private static boolean jniLoaded;
|
||||
|
||||
private final SurfaceHolder.Callback surfCallbacks;
|
||||
private final View.OnFocusChangeListener focusCallback;
|
||||
private final InputMethodManager imm;
|
||||
private final float scrollXScale;
|
||||
private final float scrollYScale;
|
||||
@@ -113,12 +113,6 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal
|
||||
nhandle = onCreateView(this);
|
||||
setFocusable(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() {
|
||||
@Override public void surfaceCreated(SurfaceHolder holder) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!accessManager.isTouchExplorationEnabled()) {
|
||||
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() {
|
||||
if (nhandle != 0) {
|
||||
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 onA11yFocus(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 String imeSnippet(long handle);
|
||||
static private native int imeSnippetStart(long handle);
|
||||
|
||||
+52
-2
@@ -3,7 +3,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"gioui.org/io/event"
|
||||
"golang.org/x/net/idna"
|
||||
"image"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -56,6 +59,15 @@ type FrameEvent struct {
|
||||
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
|
||||
// current display protocol.
|
||||
type ViewEvent interface {
|
||||
@@ -118,8 +130,7 @@ func NewContext(ops *op.Ops, e FrameEvent) layout.Context {
|
||||
// On iOS NSDocumentDirectory is queried.
|
||||
// For Android Context.getFilesDir is used.
|
||||
//
|
||||
// BUG: DataDir blocks on Android until init functions
|
||||
// have completed.
|
||||
// BUG: On Android, DataDir panics if called before main.
|
||||
func DataDir() (string, error) {
|
||||
return dataDir()
|
||||
}
|
||||
@@ -136,7 +147,29 @@ func Main() {
|
||||
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 (URLEvent) ImplementsEvent() {}
|
||||
|
||||
func init() {
|
||||
if extraArgs != "" {
|
||||
@@ -147,3 +180,20 @@ func init() {
|
||||
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,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build !android
|
||||
// +build !android
|
||||
|
||||
package app
|
||||
|
||||
|
||||
+6
-1
@@ -48,7 +48,7 @@ For example, to display a blank but otherwise functional window:
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
w := app.NewWindow()
|
||||
w := new(app.Window)
|
||||
for {
|
||||
w.Event()
|
||||
}
|
||||
@@ -56,6 +56,11 @@ For example, to display a blank but otherwise functional window:
|
||||
app.Main()
|
||||
}
|
||||
|
||||
# Events
|
||||
|
||||
The [Events] iterator yields app-specific events such as [URLEvent]. [Window.Event]
|
||||
yields events that target a particular window.
|
||||
|
||||
# Permissions
|
||||
|
||||
The packages under gioui.org/app/permission should be imported
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build darwin && ios && nometal
|
||||
// +build darwin,ios,nometal
|
||||
|
||||
package app
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build darwin && !ios && nometal
|
||||
// +build darwin,!ios,nometal
|
||||
|
||||
package app
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
|
||||
@@ -108,6 +108,12 @@ type MonitorInfo struct {
|
||||
Flags uint32
|
||||
}
|
||||
|
||||
type CopyDataStruct struct {
|
||||
DwData uintptr
|
||||
CbData uint32
|
||||
LpData uintptr
|
||||
}
|
||||
|
||||
type POINTER_INPUT_TYPE int32
|
||||
|
||||
const (
|
||||
@@ -315,6 +321,7 @@ const (
|
||||
WM_CANCELMODE = 0x001F
|
||||
WM_CHAR = 0x0102
|
||||
WM_CLOSE = 0x0010
|
||||
WM_COPYDATA = 0x004A
|
||||
WM_CREATE = 0x0001
|
||||
WM_DPICHANGED = 0x02E0
|
||||
WM_DESTROY = 0x0002
|
||||
@@ -419,6 +426,7 @@ var (
|
||||
_DefWindowProc = user32.NewProc("DefWindowProcW")
|
||||
_DestroyWindow = user32.NewProc("DestroyWindow")
|
||||
_DispatchMessage = user32.NewProc("DispatchMessageW")
|
||||
_FindWindow = user32.NewProc("FindWindowW")
|
||||
_EmptyClipboard = user32.NewProc("EmptyClipboard")
|
||||
_EnableMouseInPointer = user32.NewProc("EnableMouseInPointer")
|
||||
_GetWindowRect = user32.NewProc("GetWindowRect")
|
||||
@@ -452,6 +460,7 @@ var (
|
||||
_ReleaseDC = user32.NewProc("ReleaseDC")
|
||||
_ScreenToClient = user32.NewProc("ScreenToClient")
|
||||
_ShowWindow = user32.NewProc("ShowWindow")
|
||||
_SendMessage = user32.NewProc("SendMessageW")
|
||||
_SetCapture = user32.NewProc("SetCapture")
|
||||
_SetCursor = user32.NewProc("SetCursor")
|
||||
_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) {
|
||||
wname := syscall.StringToUTF16Ptr(lpWindowName)
|
||||
wname, err := syscall.UTF16PtrFromString(lpWindowName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("CreateWindowEx failed: %v", err)
|
||||
}
|
||||
hwnd, _, err := _CreateWindowEx.Call(
|
||||
uintptr(dwExStyle),
|
||||
uintptr(lpClassName),
|
||||
@@ -576,6 +588,18 @@ func EmptyClipboard() error {
|
||||
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 {
|
||||
var r Rect
|
||||
_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) {
|
||||
wname := syscall.StringToUTF16Ptr(title)
|
||||
wname, err := syscall.UTF16PtrFromString(title)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_SetWindowText.Call(uintptr(hwnd), uintptr(unsafe.Pointer(wname)))
|
||||
}
|
||||
|
||||
@@ -883,6 +910,14 @@ func ReleaseDC(hdc syscall.Handle) {
|
||||
_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) {
|
||||
_SetForegroundWindow.Call(uintptr(hwnd))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build darwin && ios
|
||||
// +build darwin,ios
|
||||
|
||||
package app
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build !nometal
|
||||
// +build !nometal
|
||||
|
||||
package app
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ type Config struct {
|
||||
CustomRenderer bool
|
||||
// Decorated reports whether window decorations are provided automatically.
|
||||
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
|
||||
// decoHeight is the height of the fallback decoration for platforms such
|
||||
// as Wayland that may need fallback client-side decorations.
|
||||
|
||||
+17
-9
@@ -136,6 +136,8 @@ import (
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"gioui.org/io/transfer"
|
||||
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/op"
|
||||
|
||||
@@ -146,7 +148,6 @@ import (
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/io/transfer"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
@@ -217,8 +218,6 @@ type AndroidViewEvent struct {
|
||||
|
||||
type jvalue uint64 // The largest JNI type fits in 64 bits.
|
||||
|
||||
var dataDirChan = make(chan string, 1)
|
||||
|
||||
var android struct {
|
||||
// mu protects all fields of this structure. However, once a
|
||||
// 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 (
|
||||
dataDirOnce sync.Once
|
||||
dataPath string
|
||||
dataPath string
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -342,9 +340,9 @@ func (w *window) NewContext() (context, error) {
|
||||
}
|
||||
|
||||
func dataDir() (string, error) {
|
||||
dataDirOnce.Do(func() {
|
||||
dataPath = <-dataDirChan
|
||||
})
|
||||
if dataPath == "" {
|
||||
panic("DataDir isn't valid before main")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
dataDirChan <- dataDir
|
||||
dataPath = dataDir
|
||||
C.jni_ReleaseByteArrayElements(env, jdataDir, dirBytes)
|
||||
|
||||
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) {
|
||||
w.processEvent(e)
|
||||
}
|
||||
@@ -1318,6 +1325,7 @@ func findClass(env *C.JNIEnv, name string) C.jclass {
|
||||
}
|
||||
|
||||
func osMain() {
|
||||
select {}
|
||||
}
|
||||
|
||||
func newWindow(window *callbacks, options []Option) {
|
||||
|
||||
+14
-4
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build darwin && ios
|
||||
// +build darwin,ios
|
||||
|
||||
package app
|
||||
|
||||
@@ -405,11 +404,12 @@ const (
|
||||
)
|
||||
|
||||
func osMain() {
|
||||
if !isMainThread() {
|
||||
panic("app.Main must be run on the main goroutine")
|
||||
}
|
||||
switch mainMode {
|
||||
case mainModeUndefined:
|
||||
if !isMainThread() {
|
||||
panic("app.Main must be run on the main goroutine")
|
||||
}
|
||||
|
||||
mainMode = mainModeExe
|
||||
var argv []*C.char
|
||||
for _, arg := range os.Args {
|
||||
@@ -423,6 +423,16 @@ func osMain() {
|
||||
case mainModeLibrary:
|
||||
// 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
|
||||
|
||||
+51
-11
@@ -134,23 +134,59 @@ NSArray<UIKeyCommand *> *_keyCommands;
|
||||
return gio_layerClass();
|
||||
}
|
||||
- (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) {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self
|
||||
name:UIWindowDidBecomeKeyNotification
|
||||
object:self.window];
|
||||
name:UIWindowDidBecomeKeyNotification
|
||||
object:self.window];
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self
|
||||
name:UIWindowDidResignKeyNotification
|
||||
object:self.window];
|
||||
name:UIWindowDidResignKeyNotification
|
||||
object:self.window];
|
||||
}
|
||||
self.contentScaleFactor = newWindow.screen.nativeScale;
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(onWindowDidBecomeKey:)
|
||||
name:UIWindowDidBecomeKeyNotification
|
||||
object:newWindow];
|
||||
selector:@selector(onWindowDidBecomeKey:)
|
||||
name:UIWindowDidBecomeKeyNotification
|
||||
object:newWindow];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(onWindowDidResignKey:)
|
||||
name:UIWindowDidResignKeyNotification
|
||||
object:newWindow];
|
||||
selector:@selector(onWindowDidResignKey:)
|
||||
name:UIWindowDidResignKeyNotification
|
||||
object:newWindow];
|
||||
}
|
||||
|
||||
- (void)onWindowDidBecomeKey:(NSNotification *)note {
|
||||
@@ -293,6 +329,10 @@ void gio_viewSetHandle(CFTypeRef viewRef, uintptr_t handle) {
|
||||
[self.window makeKeyAndVisible];
|
||||
return YES;
|
||||
}
|
||||
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
|
||||
gio_onOpenURI((__bridge CFTypeRef)url.absoluteString);
|
||||
return YES;
|
||||
}
|
||||
@end
|
||||
|
||||
int gio_applicationMain(int argc, char *argv[]) {
|
||||
|
||||
+352
-18
@@ -53,7 +53,8 @@ type window struct {
|
||||
screenOrientation js.Value
|
||||
cleanfuncs []func()
|
||||
touches []js.Value
|
||||
composing bool
|
||||
composing int
|
||||
lastCursor int
|
||||
requestFocus bool
|
||||
|
||||
config Config
|
||||
@@ -84,6 +85,7 @@ func newWindow(win *callbacks, options []Option) {
|
||||
clipboard: js.Global().Get("navigator").Get("clipboard"),
|
||||
wakeups: make(chan struct{}, 1),
|
||||
w: win,
|
||||
composing: -1,
|
||||
}
|
||||
w.w.SetDriver(w)
|
||||
w.requestAnimationFrame = w.window.Get("requestAnimationFrame")
|
||||
@@ -130,8 +132,10 @@ func getContainer(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")
|
||||
// Position absolute so left/top coordinates actually place the element
|
||||
style.Set("position", "absolute")
|
||||
style.Set("width", "1px")
|
||||
style.Set("height", "1px")
|
||||
style.Set("opacity", "0")
|
||||
@@ -141,6 +145,12 @@ func createTextArea(doc js.Value) js.Value {
|
||||
tarea.Set("autocorrect", "off")
|
||||
tarea.Set("autocapitalize", "off")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -263,7 +273,15 @@ func (w *window) addEventListeners() {
|
||||
return nil
|
||||
})
|
||||
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.lastCursor = 0 // Reset cursor tracking on blur
|
||||
w.processEvent(ConfigEvent{Config: w.config})
|
||||
w.blur()
|
||||
return nil
|
||||
@@ -277,19 +295,205 @@ func (w *window) addEventListeners() {
|
||||
return nil
|
||||
})
|
||||
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
|
||||
})
|
||||
w.addEventListener(w.tarea, "compositionend", func(this js.Value, args []js.Value) interface{} {
|
||||
w.composing = false
|
||||
w.flushInput()
|
||||
finalText := w.tarea.Get("value").String()
|
||||
|
||||
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
|
||||
})
|
||||
w.addEventListener(w.tarea, "input", func(this js.Value, args []js.Value) interface{} {
|
||||
if w.composing {
|
||||
return nil
|
||||
e := args[0]
|
||||
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
|
||||
})
|
||||
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"))
|
||||
}
|
||||
|
||||
func (w *window) flushInput() {
|
||||
val := w.tarea.Get("value").String()
|
||||
w.tarea.Set("value", "")
|
||||
w.w.EditorInsert(string(val))
|
||||
}
|
||||
|
||||
func (w *window) blur() {
|
||||
w.tarea.Call("blur")
|
||||
w.requestFocus = false
|
||||
@@ -343,11 +541,49 @@ func (w *window) keyboard(hint key.InputHint) {
|
||||
m = "text"
|
||||
}
|
||||
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) {
|
||||
k := e.Get("key").String()
|
||||
|
||||
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{
|
||||
Name: n,
|
||||
Modifiers: modifiersFor(e),
|
||||
@@ -414,6 +650,12 @@ func modifiersFor(e js.Value) key.Modifiers {
|
||||
if e.Call("getModifierState", "Shift").Bool() {
|
||||
mods |= key.ModShift
|
||||
}
|
||||
if e.Call("getModifierState", "Meta").Bool() {
|
||||
mods |= key.ModCommand
|
||||
}
|
||||
if e.Call("getModifierState", "OS").Bool() {
|
||||
mods |= key.ModSuper
|
||||
}
|
||||
return mods
|
||||
}
|
||||
|
||||
@@ -434,6 +676,9 @@ func (w *window) touchEvent(kind pointer.Kind, e js.Value) {
|
||||
if e.Get("ctrlKey").Bool() {
|
||||
mods |= key.ModCtrl
|
||||
}
|
||||
if e.Get("metaKey").Bool() {
|
||||
mods |= key.ModCommand
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
touch := changedTouches.Index(i)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
w.animating = anim
|
||||
@@ -535,10 +863,9 @@ func (w *window) ReadClipboard() {
|
||||
if w.clipboard.IsUndefined() {
|
||||
return
|
||||
}
|
||||
if w.clipboard.Get("readText").IsUndefined() {
|
||||
return
|
||||
if w.clipboard.Get("readText").Truthy() {
|
||||
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) {
|
||||
@@ -621,6 +948,13 @@ func (w *window) ShowTextInput(show bool) {
|
||||
if show {
|
||||
w.focus()
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@autoreleasepool {
|
||||
NSWindow *window = (__bridge NSWindow *)windowRef;
|
||||
@@ -495,6 +502,9 @@ func (w *window) Configure(options []Option) {
|
||||
barTrans = C.YES
|
||||
titleVis = C.NSWindowTitleHidden
|
||||
}
|
||||
if cnf.TopMost {
|
||||
C.setWindowLevel(window, C.NSFloatingWindowLevel)
|
||||
}
|
||||
C.setWindowTitlebarAppearsTransparent(window, barTrans)
|
||||
C.setWindowTitleVisibility(window, titleVis)
|
||||
C.setWindowStyleMask(window, mask)
|
||||
@@ -997,6 +1007,16 @@ func gio_onFinishLaunching() {
|
||||
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) {
|
||||
<-launched
|
||||
res := make(chan struct{})
|
||||
|
||||
@@ -421,6 +421,11 @@ void gio_viewSetHandle(CFTypeRef viewRef, uintptr_t handle) {
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
}
|
||||
- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
|
||||
for (NSURL *url in urls) {
|
||||
gio_onOpenURI((__bridge CFTypeRef)url.absoluteString);
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
void gio_main() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build (linux && !android) || freebsd || openbsd
|
||||
// +build linux,!android freebsd openbsd
|
||||
|
||||
package app
|
||||
|
||||
|
||||
+267
-34
@@ -5,8 +5,12 @@ package app
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gioui.org/io/transfer"
|
||||
syscall "golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"image"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -16,8 +20,6 @@ import (
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
|
||||
syscall "golang.org/x/sys/windows"
|
||||
|
||||
"gioui.org/app/internal/windows"
|
||||
"gioui.org/op"
|
||||
"gioui.org/unit"
|
||||
@@ -28,7 +30,6 @@ import (
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/io/transfer"
|
||||
)
|
||||
|
||||
type Win32ViewEvent struct {
|
||||
@@ -56,6 +57,8 @@ type window struct {
|
||||
|
||||
const _WM_WAKEUP = windows.WM_USER + iota
|
||||
|
||||
const copyDataURLType = 0xffffff00
|
||||
|
||||
type gpuAPI struct {
|
||||
priority int
|
||||
initializer func(w *window) (context, error)
|
||||
@@ -81,6 +84,7 @@ var resources struct {
|
||||
}
|
||||
|
||||
func osMain() {
|
||||
processURLEvent(startupURI())
|
||||
select {}
|
||||
}
|
||||
|
||||
@@ -132,13 +136,19 @@ func initResources() error {
|
||||
}
|
||||
resources.cursor = c
|
||||
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{
|
||||
CbSize: uint32(unsafe.Sizeof(windows.WndClassEx{})),
|
||||
Style: windows.CS_HREDRAW | windows.CS_VREDRAW | windows.CS_OWNDC,
|
||||
LpfnWndProc: syscall.NewCallback(windowProc),
|
||||
HInstance: hInst,
|
||||
HIcon: icon,
|
||||
LpszClassName: syscall.StringToUTF16Ptr("GioWindow"),
|
||||
LpszClassName: appid,
|
||||
}
|
||||
cls, err := windows.RegisterClassEx(&wcls)
|
||||
if err != nil {
|
||||
@@ -359,8 +369,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
|
||||
w.update()
|
||||
case windows.WM_WINDOWPOSCHANGED:
|
||||
w.update()
|
||||
case windows.WM_SIZE:
|
||||
w.update()
|
||||
return 0
|
||||
case windows.WM_GETMINMAXINFO:
|
||||
mm := (*windows.MinMaxInfo)(unsafe.Pointer(lParam))
|
||||
|
||||
@@ -446,6 +455,20 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
|
||||
case windows.WM_IME_ENDCOMPOSITION:
|
||||
w.w.SetComposingRegion(key.Range{Start: -1, End: -1})
|
||||
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)
|
||||
@@ -471,34 +494,32 @@ func getModifiers() key.Modifiers {
|
||||
// hitTest returns the non-client area hit by the point, needed to
|
||||
// process WM_NCHITTEST.
|
||||
func (w *window) hitTest(x, y int) uintptr {
|
||||
if w.config.Mode != Windowed {
|
||||
// Only windowed mode should allow resizing.
|
||||
return windows.HTCLIENT
|
||||
}
|
||||
// Check for resize handle before system actions; otherwise it can be impossible to
|
||||
// resize a custom-decorations window when the system move area is flush with the
|
||||
// edge of the window.
|
||||
top := y <= w.borderSize.Y
|
||||
bottom := y >= w.config.Size.Y-w.borderSize.Y
|
||||
left := x <= w.borderSize.X
|
||||
right := x >= w.config.Size.X-w.borderSize.X
|
||||
switch {
|
||||
case top && left:
|
||||
return windows.HTTOPLEFT
|
||||
case top && right:
|
||||
return windows.HTTOPRIGHT
|
||||
case bottom && left:
|
||||
return windows.HTBOTTOMLEFT
|
||||
case bottom && right:
|
||||
return windows.HTBOTTOMRIGHT
|
||||
case top:
|
||||
return windows.HTTOP
|
||||
case bottom:
|
||||
return windows.HTBOTTOM
|
||||
case left:
|
||||
return windows.HTLEFT
|
||||
case right:
|
||||
return windows.HTRIGHT
|
||||
if w.config.Mode == Windowed {
|
||||
// Check for resize handle before system actions; otherwise it can be impossible to
|
||||
// resize a custom-decorations window when the system move area is flush with the
|
||||
// edge of the window.
|
||||
top := y <= w.borderSize.Y
|
||||
bottom := y >= w.config.Size.Y-w.borderSize.Y
|
||||
left := x <= w.borderSize.X
|
||||
right := x >= w.config.Size.X-w.borderSize.X
|
||||
switch {
|
||||
case top && left:
|
||||
return windows.HTTOPLEFT
|
||||
case top && right:
|
||||
return windows.HTTOPRIGHT
|
||||
case bottom && left:
|
||||
return windows.HTBOTTOMLEFT
|
||||
case bottom && right:
|
||||
return windows.HTBOTTOMRIGHT
|
||||
case top:
|
||||
return windows.HTTOP
|
||||
case bottom:
|
||||
return windows.HTBOTTOM
|
||||
case left:
|
||||
return windows.HTLEFT
|
||||
case right:
|
||||
return windows.HTRIGHT
|
||||
}
|
||||
}
|
||||
p := f32.Pt(float32(x), float32(y))
|
||||
if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove {
|
||||
@@ -1052,3 +1073,215 @@ func getPointerButtons(pi windows.PointerInfo) pointer.Buttons {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build android || (darwin && ios)
|
||||
// +build android darwin,ios
|
||||
|
||||
package app
|
||||
|
||||
@@ -25,6 +24,6 @@ func runMain() {
|
||||
// Indirect call, since the linker does not know the address of main when
|
||||
// laying down this package.
|
||||
fn := mainMain
|
||||
fn()
|
||||
go fn()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build !novulkan
|
||||
// +build !novulkan
|
||||
|
||||
package app
|
||||
|
||||
|
||||
+18
-5
@@ -142,11 +142,8 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
|
||||
if w.gpu == nil && !w.nocontext {
|
||||
var err error
|
||||
if w.ctx == nil {
|
||||
if w.ctx, err = w.driver.NewContext(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = w.ctx.Lock(); err != nil {
|
||||
w.destroyGPU()
|
||||
w.ctx, err = w.driver.NewContext()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sync = true
|
||||
@@ -166,6 +163,12 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.ctx != nil {
|
||||
if err := w.ctx.Lock(); err != nil {
|
||||
w.destroyGPU()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.gpu == nil && !w.nocontext {
|
||||
gpu, err := gpu.New(w.ctx.API())
|
||||
if err != nil {
|
||||
@@ -197,6 +200,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops,
|
||||
var err error
|
||||
if w.gpu != nil {
|
||||
err = w.ctx.Present()
|
||||
w.ctx.Unlock()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -965,6 +969,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
|
||||
// has completed processing of all prior events. Its an
|
||||
// [io/event.Event] but only for internal use.
|
||||
|
||||
@@ -106,7 +106,7 @@ func parseLoader(ld *opentype.Loader) (*fontapi.Font, giofont.Font, error) {
|
||||
// Face many be invoked any number of times and is safe so long as each return value is
|
||||
// only used by one goroutine.
|
||||
func (f Face) Face() *fontapi.Face {
|
||||
return &fontapi.Face{Font: f.face}
|
||||
return fontapi.NewFace(f.face)
|
||||
}
|
||||
|
||||
// FontFace returns a text.Font with populated font metadata for the
|
||||
|
||||
+3
-12
@@ -61,12 +61,8 @@ func (h *Hover) Update(q input.Source) bool {
|
||||
h.entered = false
|
||||
}
|
||||
case pointer.Enter:
|
||||
if !h.entered {
|
||||
h.pid = e.PointerID
|
||||
}
|
||||
if h.pid == e.PointerID {
|
||||
h.entered = true
|
||||
}
|
||||
h.pid = e.PointerID
|
||||
h.entered = true
|
||||
}
|
||||
}
|
||||
return h.entered
|
||||
@@ -222,12 +218,7 @@ func (c *Click) Update(q input.Source) (ClickEvent, bool) {
|
||||
if e.Source == pointer.Mouse && e.Buttons != pointer.ButtonPrimary {
|
||||
break
|
||||
}
|
||||
if !c.hovered {
|
||||
c.pid = e.PointerID
|
||||
}
|
||||
if c.pid != e.PointerID {
|
||||
break
|
||||
}
|
||||
c.pid = e.PointerID
|
||||
c.pressed = true
|
||||
if e.Time-c.clickedAt < doubleClickDuration {
|
||||
c.clicks++
|
||||
|
||||
@@ -100,6 +100,78 @@ func TestMouseClicks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClickPointerIDReassignment(t *testing.T) {
|
||||
// A Click must accept a Press from a PointerID that differs from the
|
||||
// one its hovered state was previously associated with. Some backends
|
||||
// reassign a single physical pointer's ID over its lifetime — e.g. the
|
||||
// Windows pointer API across focus changes — and locking the gesture
|
||||
// to the first observed ID would silently drop every subsequent press.
|
||||
//
|
||||
// The sequence below puts the gesture into the buggy state through
|
||||
// public events alone: a press under PointerID 1 starts an active
|
||||
// press cycle, a Move under PointerID 2 arrives mid-press (which the
|
||||
// router routes as an Enter for PID 2 but the gesture's Enter handler
|
||||
// is a no-op for pid while pressed), then PID 1 releases. After this,
|
||||
// the router has the gesture entered for PID 2 (so the next event
|
||||
// under PID 2 won't trigger another Enter) but the gesture itself
|
||||
// still has pid=1.
|
||||
var click Click
|
||||
var ops op.Ops
|
||||
rect := image.Rect(0, 0, 100, 100)
|
||||
stack := clip.Rect(rect).Push(&ops)
|
||||
click.Add(&ops)
|
||||
stack.Pop()
|
||||
|
||||
var r input.Router
|
||||
click.Update(r.Source())
|
||||
r.Frame(&ops)
|
||||
|
||||
drain := func() {
|
||||
for {
|
||||
if _, ok := click.Update(r.Source()); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Press under PointerID 1.
|
||||
r.Queue(
|
||||
pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1},
|
||||
pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 1},
|
||||
)
|
||||
drain()
|
||||
|
||||
// Move under PointerID 2 while PointerID 1 is still pressed. The
|
||||
// router records the gesture as entered for PointerID 2 but the
|
||||
// gesture's Enter handler is a no-op for pid because c.pressed.
|
||||
r.Queue(pointer.Event{Kind: pointer.Move, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 2})
|
||||
drain()
|
||||
|
||||
// Release PointerID 1. PointerID 1's press tracking ends; the
|
||||
// gesture's recorded pid stays at 1.
|
||||
r.Queue(pointer.Event{Kind: pointer.Release, Source: pointer.Mouse, Position: f32.Pt(50, 50), PointerID: 1})
|
||||
drain()
|
||||
|
||||
// Press under PointerID 2. The router won't refire Enter for PID 2
|
||||
// (the gesture is already in PID 2's entered set), so the gesture's
|
||||
// only chance to refresh its pid is the Press handler itself.
|
||||
r.Queue(pointer.Event{Kind: pointer.Press, Source: pointer.Mouse, Buttons: pointer.ButtonPrimary, Position: f32.Pt(50, 50), PointerID: 2})
|
||||
|
||||
var sawPress bool
|
||||
for {
|
||||
ev, ok := click.Update(r.Source())
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ev.Kind == KindPress {
|
||||
sawPress = true
|
||||
}
|
||||
}
|
||||
if !sawPress {
|
||||
t.Fatal("expected KindPress for press under reassigned PointerID; gesture dropped the press because of stale recorded pid")
|
||||
}
|
||||
}
|
||||
|
||||
func mouseClickEvents(times ...time.Duration) []event.Event {
|
||||
press := pointer.Event{
|
||||
Kind: pointer.Press,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
module gioui.org
|
||||
|
||||
go 1.23.8
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d
|
||||
gioui.org/shader v1.0.8
|
||||
github.com/go-text/typesetting v0.3.0
|
||||
github.com/go-text/typesetting v0.3.4
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/sys v0.33.0
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/text v0.32.0
|
||||
)
|
||||
|
||||
require golang.org/x/net v0.48.0
|
||||
|
||||
@@ -3,17 +3,19 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8v
|
||||
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
|
||||
gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA=
|
||||
gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
|
||||
github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
|
||||
github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
|
||||
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
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
@@ -4,7 +4,8 @@ package gpu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"gioui.org/internal/f32"
|
||||
)
|
||||
|
||||
type textureCacheKey struct {
|
||||
@@ -35,7 +36,7 @@ type opCache struct {
|
||||
type opCacheValue struct {
|
||||
data pathData
|
||||
|
||||
bounds image.Rectangle
|
||||
bounds f32.Rectangle
|
||||
// the fields below are handled by opCache
|
||||
key opKey
|
||||
keep bool
|
||||
|
||||
+50
-50
@@ -118,17 +118,17 @@ type drawState struct {
|
||||
}
|
||||
|
||||
type pathOp struct {
|
||||
off image.Point
|
||||
off f32.Point
|
||||
// rect tracks whether the clip stack can be represented by a
|
||||
// pixel-aligned rectangle.
|
||||
rect bool
|
||||
// clip is the union of all
|
||||
// later clip rectangles.
|
||||
clip image.Rectangle
|
||||
bounds image.Rectangle
|
||||
bounds f32.Rectangle
|
||||
// intersect is the intersection of bounds and all
|
||||
// previous clip bounds.
|
||||
intersect image.Rectangle
|
||||
intersect f32.Rectangle
|
||||
pathKey opKey
|
||||
path bool
|
||||
pathVerts []byte
|
||||
@@ -902,14 +902,16 @@ func (d *drawOps) reset(viewport image.Point) {
|
||||
d.opacityStack = d.opacityStack[:0]
|
||||
}
|
||||
|
||||
func (d *drawOps) collect(root *op.Ops, viewportSize image.Point) {
|
||||
viewport := image.Rectangle{Max: viewportSize}
|
||||
func (d *drawOps) collect(root *op.Ops, viewport image.Point) {
|
||||
viewf := f32.Rectangle{
|
||||
Max: f32.Point{X: float32(viewport.X), Y: float32(viewport.Y)},
|
||||
}
|
||||
var ops *ops.Ops
|
||||
if root != nil {
|
||||
ops = &root.Internal
|
||||
}
|
||||
d.reader.Reset(ops)
|
||||
d.collectOps(&d.reader, viewport)
|
||||
d.collectOps(&d.reader, viewf)
|
||||
}
|
||||
|
||||
func (d *drawOps) buildPaths(ctx driver.Device) {
|
||||
@@ -930,7 +932,7 @@ func (d *drawOps) newPathOp() *pathOp {
|
||||
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 = pathOp{
|
||||
parent: state.cpath,
|
||||
@@ -971,7 +973,7 @@ func (k opKey) SetTransform(t f32.Affine2D) opKey {
|
||||
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
|
||||
state := drawState{
|
||||
t: f32.AffineId(),
|
||||
@@ -1033,7 +1035,7 @@ loop:
|
||||
var op ops.ClipOp
|
||||
op.Decode(encOp.Data)
|
||||
quads.key.outline = op.Outline
|
||||
bounds := op.Bounds
|
||||
bounds := f32.FRect(op.Bounds)
|
||||
trans, off := transformOffset(state.t)
|
||||
if len(quads.aux) > 0 {
|
||||
// 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?
|
||||
bounds = v.bounds
|
||||
} else {
|
||||
newPathData, newBounds := d.buildVerts(
|
||||
var pathData []byte
|
||||
pathData, bounds = d.buildVerts(
|
||||
quads.aux, trans, quads.key.outline, quads.key.strokeWidth,
|
||||
)
|
||||
quads.aux = newPathData
|
||||
bounds = newBounds.Round()
|
||||
quads.aux = pathData
|
||||
// add it to the cache, without GPU data, so the transform can be
|
||||
// reused.
|
||||
d.pathCache.put(quads.key, opCacheValue{bounds: bounds})
|
||||
@@ -1084,18 +1086,18 @@ loop:
|
||||
t, off := transformOffset(state.t)
|
||||
// Fill the clip area, unless the material is a (bounded) image.
|
||||
// TODO: Find a tighter bound.
|
||||
inf := int(1e6)
|
||||
dst := image.Rect(-inf, -inf, inf, inf)
|
||||
inf := float32(1e6)
|
||||
dst := f32.Rect(-inf, -inf, inf, inf)
|
||||
if state.matType == materialTexture {
|
||||
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)
|
||||
bounds := viewport.Intersect(bnd.Add(off))
|
||||
cl := viewport.Intersect(bnd.Add(off))
|
||||
if state.cpath != nil {
|
||||
bounds = state.cpath.intersect.Intersect(bounds)
|
||||
cl = state.cpath.intersect.Intersect(cl)
|
||||
}
|
||||
if bounds.Empty() {
|
||||
if cl.Empty() {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1107,6 +1109,7 @@ loop:
|
||||
d.addClipPath(&state, clipData, k, bnd, off)
|
||||
}
|
||||
|
||||
bounds := cl.Round()
|
||||
mat := state.materialFor(bnd, off, partialTrans, bounds)
|
||||
|
||||
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{
|
||||
opacity: 1.,
|
||||
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))
|
||||
case materialTexture:
|
||||
m.material = materialTexture
|
||||
dr := rect.Add(off)
|
||||
dr := rect.Add(off).Round()
|
||||
sz := d.image.src.Bounds().Size()
|
||||
sr := f32.Rectangle{
|
||||
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)].
|
||||
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)
|
||||
l := float32(math.Sqrt(float64(d.X*d.X + d.Y*d.Y)))
|
||||
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
|
||||
zp := f32.Point{}
|
||||
return f32.AffineId().
|
||||
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(stop1)). // offset to first stop point
|
||||
Rotate(zp, a). // rotate to align gradient
|
||||
Scale(zp, f32.Pt(1/l, 1/l)) // scale gradient to right size
|
||||
Scale(zp, layout.FPt(clip.Size())). // scale to pixel space
|
||||
Offset(zp.Sub(off).Add(layout.FPt(clip.Min))). // offset to clip space
|
||||
Offset(zp.Sub(stop1)). // offset to first stop point
|
||||
Rotate(zp, a). // rotate to align gradient
|
||||
Scale(zp, f32.Pt(1/l, 1/l)) // scale gradient to right size
|
||||
}
|
||||
|
||||
// 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.
|
||||
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()
|
||||
if tr == f32.AffineId() {
|
||||
// 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
|
||||
corners := [4]f32.Point{
|
||||
tr.Transform(f32.FPt(r.Min)), tr.Transform(f32.Pt(float32(r.Max.X), float32(r.Min.Y))),
|
||||
tr.Transform(f32.FPt(r.Max)), tr.Transform(f32.Pt(float32(r.Min.X), float32(r.Max.Y))),
|
||||
}
|
||||
fBounds := f32.Rectangle{
|
||||
Min: f32.Pt(math.MaxFloat32, math.MaxFloat32),
|
||||
Max: f32.Pt(-math.MaxFloat32, -math.MaxFloat32),
|
||||
tr.Transform(r.Min), tr.Transform(f32.Pt(r.Max.X, r.Min.Y)),
|
||||
tr.Transform(r.Max), tr.Transform(f32.Pt(r.Min.X, r.Max.Y)),
|
||||
}
|
||||
bnd.Min = f32.Pt(math.MaxFloat32, math.MaxFloat32)
|
||||
bnd.Max = f32.Pt(-math.MaxFloat32, -math.MaxFloat32)
|
||||
for _, c := range corners {
|
||||
if c.X < fBounds.Min.X {
|
||||
fBounds.Min.X = c.X
|
||||
if c.X < bnd.Min.X {
|
||||
bnd.Min.X = c.X
|
||||
}
|
||||
if c.Y < fBounds.Min.Y {
|
||||
fBounds.Min.Y = c.Y
|
||||
if c.Y < bnd.Min.Y {
|
||||
bnd.Min.Y = c.Y
|
||||
}
|
||||
if c.X > fBounds.Max.X {
|
||||
fBounds.Max.X = c.X
|
||||
if c.X > bnd.Max.X {
|
||||
bnd.Max.X = c.X
|
||||
}
|
||||
if c.Y > fBounds.Max.Y {
|
||||
fBounds.Max.Y = c.Y
|
||||
if c.Y > bnd.Max.Y {
|
||||
bnd.Max.Y = c.Y
|
||||
}
|
||||
}
|
||||
bnd = fBounds.Round()
|
||||
|
||||
// build the GPU vertices
|
||||
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
|
||||
var P1, P2, P3 f32.Point
|
||||
P1.X = (corners[1].X - fBounds.Min.X) / (fBounds.Max.X - fBounds.Min.X)
|
||||
P1.Y = (corners[1].Y - fBounds.Min.Y) / (fBounds.Max.Y - fBounds.Min.Y)
|
||||
P2.X = (corners[2].X - fBounds.Min.X) / (fBounds.Max.X - fBounds.Min.X)
|
||||
P2.Y = (corners[2].Y - fBounds.Min.Y) / (fBounds.Max.Y - fBounds.Min.Y)
|
||||
P3.X = (corners[3].X - fBounds.Min.X) / (fBounds.Max.X - fBounds.Min.X)
|
||||
P3.Y = (corners[3].Y - fBounds.Min.Y) / (fBounds.Max.Y - fBounds.Min.Y)
|
||||
P1.X = (corners[1].X - bnd.Min.X) / (bnd.Max.X - bnd.Min.X)
|
||||
P1.Y = (corners[1].Y - bnd.Min.Y) / (bnd.Max.Y - bnd.Min.Y)
|
||||
P2.X = (corners[2].X - bnd.Min.X) / (bnd.Max.X - bnd.Min.X)
|
||||
P2.Y = (corners[2].Y - bnd.Min.Y) / (bnd.Max.Y - bnd.Min.Y)
|
||||
P3.X = (corners[3].X - bnd.Min.X) / (bnd.Max.X - bnd.Min.X)
|
||||
P3.Y = (corners[3].Y - bnd.Min.Y) / (bnd.Max.Y - bnd.Min.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()
|
||||
|
||||
@@ -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
|
||||
// and the other representing the scaling, shearing and rotation and fractional
|
||||
// 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()
|
||||
iox, fox := math.Modf(float64(ox))
|
||||
ioy, foy := math.Modf(float64(oy))
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build linux || freebsd || openbsd
|
||||
// +build linux freebsd openbsd
|
||||
|
||||
package headless
|
||||
|
||||
|
||||
+3
-3
@@ -334,7 +334,7 @@ func (p *pather) begin(sizes []image.Point) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -353,14 +353,14 @@ func (s *stenciler) begin(sizes []image.Point) {
|
||||
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())
|
||||
// Transform UI coordinates to OpenGL coordinates.
|
||||
texSize := f32.Point{X: float32(bounds.Dx()), Y: float32(bounds.Dy())}
|
||||
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}
|
||||
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)
|
||||
// Draw in batches that fit in uint16 indices.
|
||||
start := 0
|
||||
|
||||
@@ -34,7 +34,7 @@ func Parse() {
|
||||
}
|
||||
print := false
|
||||
silent := false
|
||||
for _, part := range strings.Split(val, ",") {
|
||||
for part := range strings.SplitSeq(val, ",") {
|
||||
switch part {
|
||||
case textSubsystem:
|
||||
Text.Store(true)
|
||||
|
||||
@@ -41,14 +41,10 @@ var (
|
||||
_eglWaitClient *syscall.Proc
|
||||
)
|
||||
|
||||
var loadOnce sync.Once
|
||||
var loadOnce = sync.OnceValue(loadDLLs)
|
||||
|
||||
func loadEGL() error {
|
||||
var err error
|
||||
loadOnce.Do(func() {
|
||||
err = loadDLLs()
|
||||
})
|
||||
return err
|
||||
return loadOnce()
|
||||
}
|
||||
|
||||
func loadDLLs() error {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//go:build !js
|
||||
// +build !js
|
||||
|
||||
package gl
|
||||
|
||||
|
||||
@@ -152,7 +152,6 @@ func BenchmarkSplitCubic(b *testing.B) {
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
b.Run(strconv.Itoa(s.segments), func(b *testing.B) {
|
||||
from, ctrl0, ctrl1, to := s.from, s.ctrl0, s.ctrl1, s.to
|
||||
quads := make([]QuadSegment, s.segments)
|
||||
|
||||
+19
-4
@@ -739,6 +739,10 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
|
||||
state.pointers = nil
|
||||
return state, evts
|
||||
}
|
||||
if e.Kind == pointer.Scroll {
|
||||
// Scroll events are not bound to a pointer; see pointer.Event.PointerID.
|
||||
return state, q.deliverScrollEvent(handlers, evts, e)
|
||||
}
|
||||
state, pidx := state.pointerOf(e)
|
||||
p := state.pointers[pidx]
|
||||
|
||||
@@ -756,14 +760,13 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
|
||||
if p.pressed {
|
||||
p, evts = q.deliverDragEvent(handlers, p, evts)
|
||||
}
|
||||
case pointer.Leave:
|
||||
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
|
||||
case pointer.Release:
|
||||
evts = q.deliverEvent(handlers, p, evts, e)
|
||||
p.pressed = false
|
||||
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
|
||||
p, evts = q.deliverDropEvent(handlers, p, evts)
|
||||
case pointer.Scroll:
|
||||
p, evts, state.cursor, _ = q.deliverEnterLeaveEvents(handlers, state.cursor, p, evts, e)
|
||||
evts = q.deliverEvent(handlers, p, evts, e)
|
||||
default:
|
||||
panic("unsupported pointer event type")
|
||||
}
|
||||
@@ -780,6 +783,18 @@ func (q *pointerQueue) Push(handlers map[event.Tag]*handler, state pointerState,
|
||||
return state, evts
|
||||
}
|
||||
|
||||
// deliverScrollEvent delivers scroll events to the handlers hit by the event coordinate.
|
||||
func (q *pointerQueue) deliverScrollEvent(handlers map[event.Tag]*handler, evts []taggedEvent, e pointer.Event) []taggedEvent {
|
||||
var hits []event.Tag
|
||||
q.hitTest(e.Position, func(n *hitNode) bool {
|
||||
if _, ok := handlers[n.tag]; ok {
|
||||
hits = addHandler(hits, n.tag)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return q.deliverEvent(handlers, pointerInfo{handlers: hits}, evts, e)
|
||||
}
|
||||
|
||||
func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerInfo, evts []taggedEvent, e pointer.Event) []taggedEvent {
|
||||
if p.pressed && len(p.handlers) == 1 {
|
||||
e.Priority = pointer.Grabbed
|
||||
@@ -810,7 +825,7 @@ func (q *pointerQueue) deliverEvent(handlers map[event.Tag]*handler, p pointerIn
|
||||
func (q *pointerQueue) deliverEnterLeaveEvents(handlers map[event.Tag]*handler, cursor pointer.Cursor, p pointerInfo, evts []taggedEvent, e pointer.Event) (pointerInfo, []taggedEvent, pointer.Cursor, bool) {
|
||||
changed := false
|
||||
var hits []event.Tag
|
||||
if e.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press {
|
||||
if e.Kind == pointer.Leave || e.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press {
|
||||
// Consider non-mouse pointers leaving when they're released.
|
||||
} else {
|
||||
var transSrc *pointerFilter
|
||||
|
||||
@@ -255,6 +255,45 @@ func TestPointerMove(t *testing.T) {
|
||||
assertEventPointerTypeSequence(t, events(&r, -1, filter(handler2)), pointer.Enter, pointer.Move, pointer.Leave, pointer.Cancel)
|
||||
}
|
||||
|
||||
func TestPointerLeave(t *testing.T) {
|
||||
handler := new(int)
|
||||
var ops op.Ops
|
||||
|
||||
filter := pointer.Filter{
|
||||
Target: handler,
|
||||
Kinds: pointer.Move | pointer.Enter | pointer.Leave | pointer.Cancel,
|
||||
}
|
||||
defer clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops).Pop()
|
||||
event.Op(&ops, handler)
|
||||
|
||||
var r Router
|
||||
events(&r, -1, filter)
|
||||
r.Frame(&ops)
|
||||
r.Queue(
|
||||
pointer.Event{
|
||||
Kind: pointer.Move,
|
||||
Source: pointer.Mouse,
|
||||
PointerID: 1,
|
||||
Position: f32.Pt(50, 50),
|
||||
},
|
||||
pointer.Event{
|
||||
Kind: pointer.Leave,
|
||||
Source: pointer.Mouse,
|
||||
PointerID: 1,
|
||||
Position: f32.Pt(50, 50),
|
||||
},
|
||||
)
|
||||
assertEventPointerTypeSequence(t, events(&r, -1, filter), pointer.Enter, pointer.Move, pointer.Leave)
|
||||
|
||||
r.Queue(pointer.Event{
|
||||
Kind: pointer.Move,
|
||||
Source: pointer.Mouse,
|
||||
PointerID: 1,
|
||||
Position: f32.Pt(50, 50),
|
||||
})
|
||||
assertEventPointerTypeSequence(t, events(&r, -1, filter), pointer.Enter, pointer.Move)
|
||||
}
|
||||
|
||||
func TestPointerTypes(t *testing.T) {
|
||||
handler := new(int)
|
||||
var ops op.Ops
|
||||
@@ -1345,3 +1384,40 @@ func events(r *Router, n int, filters ...event.Filter) []event.Event {
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// TestPointerScrollDoesNotTrackPointer queues two events over two cursor
|
||||
// regions. The Move puts the live pointer over the button (CursorPointer);
|
||||
// the Scroll happens over the cell (CursorText) and must not update the
|
||||
// cursor.
|
||||
func TestPointerScrollDoesNotTrackPointer(t *testing.T) {
|
||||
var ops op.Ops
|
||||
|
||||
button := clip.Rect(image.Rect(0, 0, 50, 50)).Push(&ops)
|
||||
pointer.CursorPointer.Add(&ops)
|
||||
button.Pop()
|
||||
|
||||
cell := clip.Rect(image.Rect(100, 0, 200, 50)).Push(&ops)
|
||||
pointer.CursorText.Add(&ops)
|
||||
cell.Pop()
|
||||
|
||||
var r Router
|
||||
r.Frame(&ops)
|
||||
r.Queue(
|
||||
pointer.Event{
|
||||
Kind: pointer.Move,
|
||||
Source: pointer.Mouse,
|
||||
Position: f32.Pt(25, 25),
|
||||
},
|
||||
pointer.Event{
|
||||
Kind: pointer.Scroll,
|
||||
Source: pointer.Mouse,
|
||||
Position: f32.Pt(150, 25),
|
||||
Scroll: f32.Pt(0, 1),
|
||||
},
|
||||
)
|
||||
|
||||
if got, want := r.Cursor(), pointer.CursorPointer; got != want {
|
||||
t.Errorf("got %q, want %q (scroll position must not update the cursor; "+
|
||||
"the live pointer's last position is what determines it)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
//go:build !darwin && !js
|
||||
|
||||
package key
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ type Event struct {
|
||||
Source Source
|
||||
// PointerID is the id for the pointer and can be used
|
||||
// to track a particular pointer from Press to
|
||||
// Release.
|
||||
// Release. Populated for Press, Release, Move, Drag,
|
||||
// Enter, Leave, and Cancel; Scroll events are not
|
||||
// bound to a tracked pointer and leave it zero.
|
||||
PointerID ID
|
||||
// Priority is the priority of the receiving handler
|
||||
// for this event.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
//go:build !race
|
||||
// +build !race
|
||||
|
||||
package layout
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ type Flex struct {
|
||||
// size of Flexed children. If WeightSum is zero, the sum
|
||||
// of all Flexed weights is used.
|
||||
WeightSum float32
|
||||
// Gap is the space in pixels between children.
|
||||
Gap int
|
||||
}
|
||||
|
||||
// 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)
|
||||
crossMin, crossMax := f.Axis.crossConstraint(cs)
|
||||
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
|
||||
cgtx := gtx
|
||||
// Note: previously the scratch space was inside FlexChild.
|
||||
@@ -162,6 +172,9 @@ func (f Flex) Layout(gtx Context, children ...FlexChild) Dimensions {
|
||||
maxBaseline = b
|
||||
}
|
||||
}
|
||||
if len(children) > 1 && f.Gap > 0 {
|
||||
size += f.Gap * (len(children) - 1)
|
||||
}
|
||||
var space int
|
||||
if mainMin > size {
|
||||
space = mainMin - size
|
||||
@@ -199,6 +212,7 @@ func (f Flex) Layout(gtx Context, children ...FlexChild) Dimensions {
|
||||
trans.Pop()
|
||||
mainSize += f.Axis.Convert(dims.Size).X
|
||||
if i < len(children)-1 {
|
||||
mainSize += f.Gap
|
||||
switch f.Spacing {
|
||||
case SpaceEvenly:
|
||||
mainSize += space / (1 + len(children))
|
||||
|
||||
@@ -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) {
|
||||
max := image.Pt(100, 100)
|
||||
for _, tc := range []struct {
|
||||
|
||||
+20
-7
@@ -31,6 +31,8 @@ type List struct {
|
||||
Alignment Alignment
|
||||
// ScrollAnyAxis allows any scroll axis to scroll the list, not just the main axis.
|
||||
ScrollAnyAxis bool
|
||||
// Gap is the space in pixels between children.
|
||||
Gap int
|
||||
|
||||
cs Constraints
|
||||
scroll gesture.Scroll
|
||||
@@ -130,7 +132,7 @@ func (l *List) Layout(gtx Context, len int, w ListElement) Dimensions {
|
||||
}
|
||||
|
||||
if numLaidOut > 0 {
|
||||
l.Position.Length = laidOutTotalLength * len / numLaidOut
|
||||
l.Position.Length = laidOutTotalLength*len/numLaidOut + l.Gap*(len-1)
|
||||
} else {
|
||||
l.Position.Length = 0
|
||||
}
|
||||
@@ -223,11 +225,11 @@ func (l *List) nextDir() iterationDir {
|
||||
if len(l.children) > 0 {
|
||||
if l.Position.First > 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 {
|
||||
lastChild := l.children[len(l.children)-1]
|
||||
lastSize = l.Axis.Convert(lastChild.size).X
|
||||
lastSize = l.Axis.Convert(lastChild.size).X + l.Gap
|
||||
}
|
||||
}
|
||||
switch {
|
||||
@@ -245,6 +247,9 @@ func (l *List) nextDir() iterationDir {
|
||||
func (l *List) end(dims Dimensions, call op.CallOp) {
|
||||
child := scrollChild{dims.Size, call}
|
||||
mainSize := l.Axis.Convert(child.size).X
|
||||
if len(l.children) > 0 {
|
||||
l.maxSize += l.Gap
|
||||
}
|
||||
l.maxSize += mainSize
|
||||
switch l.dir {
|
||||
case iterateForward:
|
||||
@@ -254,7 +259,7 @@ func (l *List) end(dims Dimensions, call op.CallOp) {
|
||||
copy(l.children[1:], l.children)
|
||||
l.children[0] = child
|
||||
l.Position.First--
|
||||
l.Position.Offset += mainSize
|
||||
l.Position.Offset += mainSize + l.Gap
|
||||
default:
|
||||
panic("call Next before End")
|
||||
}
|
||||
@@ -279,7 +284,7 @@ func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions {
|
||||
break
|
||||
}
|
||||
l.Position.First++
|
||||
l.Position.Offset -= mainSize
|
||||
l.Position.Offset -= mainSize + l.Gap
|
||||
first = child
|
||||
children = children[1:]
|
||||
}
|
||||
@@ -291,6 +296,9 @@ func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions {
|
||||
if c := sz.Y; c > maxCross {
|
||||
maxCross = c
|
||||
}
|
||||
if i > 0 {
|
||||
size += l.Gap
|
||||
}
|
||||
size += sz.X
|
||||
if size >= mainMax {
|
||||
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.
|
||||
if first != (scrollChild{}) {
|
||||
sz := l.Axis.Convert(first.size)
|
||||
pos -= sz.X
|
||||
pos -= sz.X + l.Gap
|
||||
layout(first)
|
||||
pos += l.Gap
|
||||
}
|
||||
for _, child := range children {
|
||||
for i, child := range children {
|
||||
if i > 0 {
|
||||
pos += l.Gap
|
||||
}
|
||||
layout(child)
|
||||
}
|
||||
// Lay out trailing invisible child.
|
||||
if last != (scrollChild{}) {
|
||||
pos += l.Gap
|
||||
layout(last)
|
||||
}
|
||||
atStart := l.Position.First == 0 && l.Position.Offset <= 0
|
||||
|
||||
@@ -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) {
|
||||
var l List
|
||||
l.Position.First = 1
|
||||
|
||||
@@ -224,7 +224,7 @@ func (p *parser) parse(rule string) ([]string, error) {
|
||||
//
|
||||
// LIST ::= <FACE> <COMMA> <LIST> | <FACE>
|
||||
func (p *parser) parseList() error {
|
||||
if len(p.tokens) < 0 {
|
||||
if len(p.tokens) == 0 {
|
||||
return fmt.Errorf("expected family name, got EOF")
|
||||
}
|
||||
if head := p.tokens[0]; head.kind != tokenStr {
|
||||
|
||||
+65
-60
@@ -103,8 +103,7 @@ func (l *line) insertTrailingSyntheticNewline(newLineClusterIdx int) {
|
||||
clusterIndex: newLineClusterIdx,
|
||||
glyphCount: 0,
|
||||
runeCount: 1,
|
||||
xAdvance: 0,
|
||||
yAdvance: 0,
|
||||
advance: 0,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
}
|
||||
@@ -160,9 +159,9 @@ type glyph struct {
|
||||
// runeCount is the quantity of runes in the source text that this glyph
|
||||
// corresponds to.
|
||||
runeCount int
|
||||
// xAdvance and yAdvance describe the distance the dot moves when
|
||||
// laying out the glyph on the X or Y axis.
|
||||
xAdvance, yAdvance fixed.Int26_6
|
||||
// advance is the distance the dot moves when laying out the glyph along
|
||||
// the run's primary axis.
|
||||
advance fixed.Int26_6
|
||||
// xOffset and yOffset describe offsets from the dot that should be
|
||||
// applied when rendering the glyph.
|
||||
xOffset, yOffset fixed.Int26_6
|
||||
@@ -270,8 +269,9 @@ func newShaperImpl(systemFonts bool, collection []FontFace) *shaperImpl {
|
||||
// in the order in which they are loaded, with the first face being the default.
|
||||
func (s *shaperImpl) Load(f FontFace) {
|
||||
desc := opentype.FontToDescription(f.Font)
|
||||
s.fontMap.AddFace(f.Face.Face(), fontscan.Location{File: fmt.Sprint(desc)}, desc)
|
||||
s.addFace(f.Face.Face(), f.Font)
|
||||
face := f.Face.Face()
|
||||
s.fontMap.AddFace(face, fontscan.Location{File: fmt.Sprint(desc)}, desc)
|
||||
s.addFace(face, f.Font)
|
||||
}
|
||||
|
||||
func (s *shaperImpl) addFace(f *font.Face, md giofont.Font) {
|
||||
@@ -312,7 +312,7 @@ func splitByScript(inputs []shaping.Input, documentDir di.Direction, buf []shapi
|
||||
r := input.Text[i]
|
||||
runeScript := language.LookupScript(r)
|
||||
|
||||
if runeScript == language.Common || runeScript == currentInput.Script {
|
||||
if runeScript == language.Common || runeScript == language.Inherited || runeScript == currentInput.Script {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -437,8 +437,7 @@ func (s *shaperImpl) shapeText(ppem fixed.Int26_6, lc system.Locale, txt []rune)
|
||||
Height: input.Size,
|
||||
XBearing: 0,
|
||||
YBearing: 0,
|
||||
XAdvance: input.Size,
|
||||
YAdvance: input.Size,
|
||||
Advance: input.Size,
|
||||
XOffset: 0,
|
||||
YOffset: 0,
|
||||
ClusterIndex: input.RunStart,
|
||||
@@ -656,53 +655,60 @@ func (s *shaperImpl) Shape(pathOps *op.Ops, gs []Glyph) clip.PathSpec {
|
||||
}
|
||||
scaleFactor := fixedToFloat(ppem) / float32(face.Upem())
|
||||
glyphData := face.GlyphData(gid)
|
||||
|
||||
var outline font.GlyphOutline
|
||||
switch glyphData := glyphData.(type) {
|
||||
case font.GlyphOutline:
|
||||
outline := glyphData
|
||||
// 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)
|
||||
outline = glyphData
|
||||
case font.GlyphSVG:
|
||||
outline = glyphData.Outline
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -761,7 +767,7 @@ func (s *shaperImpl) Bitmaps(ops *op.Ops, gs []Glyph) op.CallOp {
|
||||
imgSize = bitmapData.size
|
||||
}
|
||||
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),
|
||||
})).Push(ops)
|
||||
cl := clip.Rect{Max: imgSize}.Push(ops)
|
||||
@@ -847,11 +853,10 @@ func toGioGlyphs(in []shaping.Glyph, ppem fixed.Int26_6, faceIdx int) []glyph {
|
||||
bounds.Max = bounds.Min.Add(fixed.Point26_6{X: g.Width, Y: -g.Height})
|
||||
out = append(out, glyph{
|
||||
id: newGlyphID(ppem, faceIdx, g.GlyphID),
|
||||
clusterIndex: g.ClusterIndex,
|
||||
runeCount: g.RuneCount,
|
||||
glyphCount: g.GlyphCount,
|
||||
xAdvance: g.XAdvance,
|
||||
yAdvance: g.YAdvance,
|
||||
clusterIndex: g.TextIndex(),
|
||||
runeCount: g.RunesCount(),
|
||||
glyphCount: g.GlyphsCount(),
|
||||
advance: g.Advance,
|
||||
xOffset: g.XOffset,
|
||||
yOffset: g.YOffset,
|
||||
bounds: bounds,
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"testing"
|
||||
|
||||
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/language"
|
||||
"github.com/go-text/typesetting/shaping"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/math/fixed"
|
||||
@@ -593,3 +595,103 @@ func TestGlyphIDPacking(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArabicDiacriticClustering verifies that Arabic diacritics (which usually have
|
||||
// script 'Inherited') are correctly clustered with their base Arabic letters,
|
||||
// rather than being split into a separate shaping run.
|
||||
func TestArabicDiacriticClustering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []rune
|
||||
wantRuns int
|
||||
wantScript language.Script
|
||||
wantDirection di.Direction
|
||||
}{
|
||||
{
|
||||
name: "Arabic Letter + Diacritic",
|
||||
// \u0628 => BEH
|
||||
// \u0650 => KASRA (Diacritic)
|
||||
input: []rune{'\u0628', '\u0650'},
|
||||
wantRuns: 1,
|
||||
wantScript: language.Arabic,
|
||||
wantDirection: di.DirectionRTL,
|
||||
},
|
||||
{
|
||||
name: "Arabic Word with Multiple Diacritics",
|
||||
input: []rune{
|
||||
'\u0628', // BEH
|
||||
'\u0650', // KASRA
|
||||
'\u0633', // SEEN
|
||||
'\u0652', // SUKUN
|
||||
'\u0645', // MEEM
|
||||
'\u0650', // KASRA
|
||||
},
|
||||
wantRuns: 1,
|
||||
wantScript: language.Arabic,
|
||||
wantDirection: di.DirectionRTL,
|
||||
},
|
||||
{
|
||||
name: "Mixed Script (CONTROL Case) #1",
|
||||
// Arabic Letter + Latin Letter
|
||||
// THESE MUST SPLIT TO 2.
|
||||
input: []rune{'\u0628', 'A'},
|
||||
wantRuns: 2,
|
||||
wantScript: language.Arabic,
|
||||
wantDirection: di.DirectionRTL,
|
||||
},
|
||||
{
|
||||
name: "Mixed Script (CONTROL Case) #2",
|
||||
// Arabic Letter + Diacritic + Diacritic + Latin Letter + Arabic Letter + Diacritic
|
||||
// THESE MUST SPLIT TO 3.
|
||||
input: []rune{'\u0628', '\u0651', '\u0650', 'A', '\u0628', '\u0650'},
|
||||
wantRuns: 3,
|
||||
wantScript: language.Arabic,
|
||||
wantDirection: di.DirectionRTL,
|
||||
},
|
||||
{
|
||||
name: "Mixed Script (A little 'stress' test)",
|
||||
// Latin 's' + Arabic Kasra + Latin 'r' + Arabic Fatha
|
||||
// this technically valid unicode!
|
||||
// the diacritics should inherit "Latin"
|
||||
input: []rune{'s', '\u0651', '\u0650', 'r', '\u064E'},
|
||||
wantRuns: 1,
|
||||
wantScript: language.Latin,
|
||||
wantDirection: di.DirectionLTR,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inputs := []shaping.Input{{
|
||||
Text: tt.input,
|
||||
RunStart: 0,
|
||||
RunEnd: len(tt.input),
|
||||
Direction: tt.wantDirection,
|
||||
Script: language.Arabic,
|
||||
Face: nil, // face doesn't really matter for splitting anyway
|
||||
Size: fixed.I(10),
|
||||
}}
|
||||
|
||||
got := splitByScript(inputs, tt.wantDirection, nil)
|
||||
|
||||
if len(got) != tt.wantRuns {
|
||||
t.Fatalf("splitByScript produced %d runs, expected %d. \nRun details: %+v", len(got), tt.wantRuns, got)
|
||||
}
|
||||
|
||||
// this is for the single-run cases
|
||||
// we need to verify the integrity of the single run
|
||||
// to ensure
|
||||
// - the truncation didn't happen early on (when first hitting a diacritic)
|
||||
// - and the right dominant script label was used
|
||||
if tt.wantRuns == 1 {
|
||||
run := got[0]
|
||||
if run.RunEnd != len(tt.input) {
|
||||
t.Errorf("Run truncated early. End = %d, expected %d", run.RunEnd, len(tt.input))
|
||||
}
|
||||
if run.Script != tt.wantScript {
|
||||
t.Errorf("Run assigned wrong script. Got %s, expected %s", run.Script, tt.wantScript)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
-7
@@ -259,10 +259,6 @@ func WithCollection(collection []FontFace) ShaperOption {
|
||||
}
|
||||
|
||||
// NewShaper constructs a shaper with the provided options.
|
||||
//
|
||||
// NewShaper must be called after [app.NewWindow], unless the [NoSystemFonts]
|
||||
// option is specified. This is an unfortunate restriction caused by some platforms
|
||||
// such as Android.
|
||||
func NewShaper(options ...ShaperOption) *Shaper {
|
||||
l := &Shaper{}
|
||||
for _, opt := range options {
|
||||
@@ -468,7 +464,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
|
||||
if rtl {
|
||||
// Modify the advance prior to computing runOffset to ensure that the
|
||||
// current glyph's width is subtracted in RTL.
|
||||
l.advance += g.xAdvance
|
||||
l.advance += g.advance
|
||||
}
|
||||
// runOffset computes how far into the run the dot should be positioned.
|
||||
runOffset := l.advance
|
||||
@@ -481,7 +477,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
|
||||
Y: int32(line.yOffset),
|
||||
Ascent: line.ascent,
|
||||
Descent: line.descent,
|
||||
Advance: g.xAdvance,
|
||||
Advance: g.advance,
|
||||
Runes: uint16(g.runeCount),
|
||||
Offset: fixed.Point26_6{
|
||||
X: g.xOffset,
|
||||
@@ -494,7 +490,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
|
||||
}
|
||||
l.glyph++
|
||||
if !rtl {
|
||||
l.advance += g.xAdvance
|
||||
l.advance += g.advance
|
||||
}
|
||||
|
||||
endOfRun := l.glyph == len(run.Glyphs)
|
||||
|
||||
+2
-2
@@ -450,8 +450,8 @@ func printLinePositioning(t *testing.T, lines []line, glyphs []Glyph) {
|
||||
for g := start; ; g += inc {
|
||||
glyph := run.Glyphs[g]
|
||||
if glyphCursor < len(glyphs) {
|
||||
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
|
||||
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.xAdvance, glyph.runeCount, glyph.glyphCount)
|
||||
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - glyphs[%2d] flags %s", g, glyph.advance, glyph.runeCount, glyph.glyphCount, glyphCursor, glyphs[glyphCursor].Flags)
|
||||
t.Logf("glyph %2d, adv %3d, runes %2d, glyphs %d - n/a", g, glyph.advance, glyph.runeCount, glyph.glyphCount)
|
||||
}
|
||||
glyphCursor++
|
||||
if g == end {
|
||||
|
||||
@@ -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.
|
||||
func TestSelectMove(t *testing.T) {
|
||||
e := new(Editor)
|
||||
|
||||
+54
-27
@@ -22,6 +22,10 @@ type lineInfo struct {
|
||||
glyphs int
|
||||
}
|
||||
|
||||
func (l lineInfo) getLineEnd() fixed.Int26_6 {
|
||||
return l.xOff + l.width
|
||||
}
|
||||
|
||||
type glyphIndex struct {
|
||||
// glyphs holds the glyphs processed.
|
||||
glyphs []text.Glyph
|
||||
@@ -231,46 +235,61 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
|
||||
}
|
||||
|
||||
func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) {
|
||||
if len(g.positions) == 0 {
|
||||
n := len(g.positions)
|
||||
if n == 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]
|
||||
return pos.runes >= runeIdx
|
||||
})
|
||||
if i > 0 {
|
||||
i--
|
||||
|
||||
notFound := i == n
|
||||
if notFound {
|
||||
return g.positions[n-1], n - 1
|
||||
}
|
||||
closest := g.positions[i]
|
||||
closestI := i
|
||||
for ; i < len(g.positions); i++ {
|
||||
if g.positions[i].runes == runeIdx {
|
||||
return g.positions[i], i
|
||||
}
|
||||
}
|
||||
return closest, closestI
|
||||
return g.positions[i], i
|
||||
}
|
||||
|
||||
func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos {
|
||||
if len(g.positions) == 0 {
|
||||
n := len(g.positions)
|
||||
if n == 0 {
|
||||
return combinedPos{}
|
||||
}
|
||||
i := sort.Search(len(g.positions), func(i int) bool {
|
||||
i := sort.Search(n, func(i int) bool {
|
||||
pos := g.positions[i]
|
||||
return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col)
|
||||
})
|
||||
if i > 0 {
|
||||
i--
|
||||
notFound := i == n
|
||||
if notFound {
|
||||
return g.positions[n-1]
|
||||
}
|
||||
prior := g.positions[i]
|
||||
if i+1 >= len(g.positions) {
|
||||
pos := g.positions[i]
|
||||
foundInNextLine := pos.lineCol.line > lineCol.line
|
||||
if foundInNextLine && i > 0 {
|
||||
prior := g.positions[i-1]
|
||||
prior.x = g.lines[lineCol.line].getLineEnd()
|
||||
return prior
|
||||
}
|
||||
next := g.positions[i+1]
|
||||
if next.lineCol != lineCol {
|
||||
return prior
|
||||
return pos
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -280,9 +299,9 @@ func dist(a, b fixed.Int26_6) fixed.Int26_6 {
|
||||
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 {
|
||||
return combinedPos{}
|
||||
return combinedPos{}, false
|
||||
}
|
||||
i := sort.Search(len(g.positions), func(i int) bool {
|
||||
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
|
||||
// positions) the zero position.
|
||||
if i == len(g.positions) {
|
||||
return g.positions[i-1]
|
||||
return g.positions[i-1], false
|
||||
}
|
||||
first := g.positions[i]
|
||||
// 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)
|
||||
// If we are *really* close to the current position candidate, just choose it.
|
||||
if distance.Round() == 0 {
|
||||
return g.positions[i]
|
||||
return g.positions[i], false
|
||||
}
|
||||
if distance < closestDist {
|
||||
closestDist = distance
|
||||
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
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
@@ -86,21 +85,10 @@ func (d DecorationsStyle) layoutDecorations(gtx layout.Context) layout.Dimension
|
||||
continue
|
||||
}
|
||||
cl := d.Decorations.Clickable(a)
|
||||
dims := cl.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
|
||||
for _, c := range cl.History() {
|
||||
drawInk(gtx, c)
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
|
||||
return inset.Layout(gtx, w)
|
||||
},
|
||||
)
|
||||
dims := Clickable(gtx, cl, func(gtx layout.Context) layout.Dimensions {
|
||||
system.ActionInputOp(a).Add(gtx.Ops)
|
||||
paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
|
||||
return inset.Layout(gtx, w)
|
||||
})
|
||||
size.X += dims.Size.X
|
||||
if size.Y < dims.Size.Y {
|
||||
|
||||
@@ -321,3 +321,10 @@ func (l ListStyle) Layout(gtx layout.Context, length int, w layout.ListElement)
|
||||
|
||||
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
@@ -173,14 +173,17 @@ func (e *textView) closestToLineCol(line, col int) combinedPos {
|
||||
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()
|
||||
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.
|
||||
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.
|
||||
firstOption := e.moveByGraphemes(pos.runes, 0)
|
||||
distance := 1
|
||||
@@ -194,9 +197,9 @@ func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) combinedPos {
|
||||
second := e.closestToRune(secondOption)
|
||||
secondDist := absFixed(second.x - x)
|
||||
if firstDist > secondDist {
|
||||
return second
|
||||
return second, false
|
||||
} else {
|
||||
return first
|
||||
return first, false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +217,11 @@ func (e *textView) MoveLines(distance int, selAct selectionAction) {
|
||||
x := caretStart.x + e.caret.xoff
|
||||
// Seek to line.
|
||||
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
|
||||
if atEndOfLine && pos.runes > 0 {
|
||||
e.caret.start = pos.runes - 1
|
||||
}
|
||||
e.caret.xoff = x - pos.x
|
||||
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
|
||||
// gtx.
|
||||
func (e *textView) caretWidth(gtx layout.Context) int {
|
||||
carWidth2 := gtx.Dp(1) / 2
|
||||
if carWidth2 < 1 {
|
||||
carWidth2 = 1
|
||||
}
|
||||
carWidth2 := max(gtx.Dp(1)/2, 1)
|
||||
return carWidth2
|
||||
}
|
||||
|
||||
@@ -428,10 +431,7 @@ func (e *textView) ScrollBounds() image.Rectangle {
|
||||
if e.SingleLine {
|
||||
if len(e.index.lines) > 0 {
|
||||
line := e.index.lines[0]
|
||||
b.Min.X = line.xOff.Floor()
|
||||
if b.Min.X > 0 {
|
||||
b.Min.X = 0
|
||||
}
|
||||
b.Min.X = min(line.xOff.Floor(), 0)
|
||||
}
|
||||
b.Max.X = e.dims.Size.X + b.Min.X - e.viewSize.X
|
||||
} else {
|
||||
@@ -472,7 +472,8 @@ func (e *textView) scrollAbs(x, y int) {
|
||||
func (e *textView) MoveCoord(pos image.Point) {
|
||||
x := fixed.I(pos.X + e.scrollOff.X)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -610,7 +611,7 @@ func (e *textView) MovePages(pages int, selAct selectionAction) {
|
||||
caret := e.closestToRune(e.caret.start)
|
||||
x := caret.x + e.caret.xoff
|
||||
y := caret.y + pages*e.viewSize.Y
|
||||
pos := e.closestToXYGraphemes(x, y)
|
||||
pos, _ := e.closestToXYGraphemes(x, y)
|
||||
e.caret.start = pos.runes
|
||||
e.caret.xoff = x - pos.x
|
||||
e.updateSelection(selAct)
|
||||
|
||||
Reference in New Issue
Block a user