Compare commits

...

88 Commits

Author SHA1 Message Date
Chris Waldon 718be79d9e app: fix automatic window decoration action processing
This commit adapts the use of the automatic window decorations to the
event processing changes introduced in v0.4.0. You must update widget
state before laying it out, not after. Doing so after (as this code used
to do) results in discarding updates.

Fixes: https://todo.sr.ht/~eliasnaur/gio/542
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2024-01-08 08:18:09 -05:00
Dominik Honnef 0073e1a167 widget: don't refer to non-existent method Clickable.Clicks
Signed-off-by: Dominik Honnef <dominik@honnef.co>
2024-01-08 08:18:09 -05:00
Dominik Honnef 32ecec5538 gesture: adjust ClickKind.String for ClickType -> ClickKind rename
Signed-off-by: Dominik Honnef <dominik@honnef.co>
2024-01-08 08:18:09 -05:00
Elias Naur 6eb33b8a56 app: [Windows] tolerate gpu.ErrDeviceLost from Refresh
Fixes: https://todo.sr.ht/~eliasnaur/gio/552
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2024-01-08 08:18:09 -05:00
Elias Naur 4617526e12 app: don't route internal wakeup events to the Router
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2024-01-08 08:18:09 -05:00
Elias Naur dbc7a900bd app: [Windows] fix restore size when leaving fullscreen
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2024-01-08 08:18:09 -05:00
Chris Waldon fb3ae95b28 widget/material: fix list scrollbar display
This commit fixes a visual misalignment in scrollbars resulting from subtle differences
in the semantics of layout.Stack and layout.Background. layout.Stack will position expanded
children according to their minimum constraint regardless of their returned size, whereas
layout.Background uses their returned size. This means that layout.Expanded widgets returning
zero dimensions are positioned correctly, but they break when converted to use layout.Background.

This commit fixes the problem by returning correct dimensions from the scrollbar track.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2024-01-08 08:18:09 -05:00
Elias Naur c458eb30f0 widget/material: add missing Update calls
Without the updates, the switch and radiobutton would use stale state
for layout.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-12-05 10:40:45 -06:00
Chris Waldon d96c954769 io/pointer: fix godoc reference to renamed type
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-11-30 08:58:40 -05:00
Egon Elbre f39245df99 layout: add Background
It's relatively common to create a widget and then add a background to
it. Using layout.Stack causes bunch of heap allocs, which we would like
to avoid whenever we can.

This adds layout.Background which is roughly the same as:

    layout.Stack{Alignment: layout.C}.Layout(gtx,
    	layout.Expanded(background),
    	layout.Stacked(widget)
    )

goos: windows
goarch: amd64
pkg: gioui.org/layout
cpu: AMD Ryzen Threadripper 2950X 16-Core Processor
     │    Stack     │             Background              │
     │    sec/op    │   sec/op     vs base                │
*-32   203.80n ± 1%   83.36n ± 3%  -59.09% (p=0.000 n=10)

     │   Stack    │             Background             │
     │    B/op    │   B/op     vs base                 │
*-32   48.00 ± 0%   0.00 ± 0%  -100.00% (p=0.000 n=10)

     │   Stack    │             Background              │
     │ allocs/op  │ allocs/op   vs base                 │
*-32   2.000 ± 0%   0.000 ± 0%  -100.00% (p=0.000 n=10)

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2023-11-25 11:50:25 -06:00
Siva 8097df9930 app: [macOS] activate app on ActionRaise if necessary
Calling window.Perform(system.ActionRaise) does not show the window on
the top if the app is currently not active. This can happen for example
if the app integrated with systray (https://pkg.go.dev/fyne.io/systray)
where the menu item launches a window, the window is not showing at the
top. It is fixed by activating the current app if necessary.

Signed-off-by: Siva Dirisala <siva.dirisala@gmail.com>
2023-11-21 10:40:57 -06:00
Elias Naur fc6e51deba .builds: fix apple builder
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-11-15 10:53:59 -06:00
Egon Elbre 5fa94ff67b op/paint: add nearest neighbor scaling
This adds support for nearest neighbor filtering,
which can be useful in quite a few scenarios.

  img := paint.NewImageOp(m)
  img.Filter = paint.FilterNearest
  img.Add(gtx.Ops)

Fixes: https://todo.sr.ht/~eliasnaur/gio/414
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2023-11-15 10:38:02 -06:00
Elias Naur 23b6f06e3e io/pointer: clarify the documentation for Event.Position
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-11-15 08:07:12 -06:00
Chris Waldon c8801fe233 widget: test update-only editor logic
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-11-10 16:17:56 -05:00
Chris Waldon 3fde0c0061 widget: [API] split text widget Update from Layout
This commit introduces Update(gtx) functions for both Selectable and Editor, allowing their
state to be updated explicitly prior to layout. This completes the transition that allows all
Gio widgets to have their state updated ahead-of-time, ensuring that there is zero frame lag
between an input event and the widget response to that event.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-11-10 14:59:06 -05:00
Chris Waldon 9d89f7c8b1 text: add system font loads to debug log
This commit adds a GIODEBUG=text log message each time a system font is resolved.
This makes it vastly easier for application authors to determine which system fonts
are being used by their application.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-11-10 08:27:43 -05:00
Egon Elbre 48bd5952b1 widget: optimize processGlyph
processGlyph does not modify the value, so there's no reason to
return the struct.

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2023-11-09 15:18:46 -05:00
Egon Elbre df8a8789a3 text: [API] reduce size of Glyph.Runes to uint16
This shrinks text.Glyph from 72B to 58B.

  LabelStatic/1000runes-RTL-arabic-32   63.62µ ± 0%   62.05µ ± 0%  -2.47% (p=0.002 n=6)

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2023-11-09 15:18:46 -05:00
Egon Elbre 62edabe137 text: use a simpler hash
The hash calculation is a significant bottleneck in caching,
replace it with a simpler "add; multiply by a prime" approach.

  LabelStatic/1000runes-RTL-arabic-32    89.75µ ± 2%   63.58µ ± 1%  -29.16% (p=0.002 n=6)

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2023-11-09 15:18:46 -05:00
Egon Elbre 49296bd0ca internal/ops: use uint32 for pc, version, macroID
4GB of render data should be sufficient for anyone.

By replacing an int with uint32, it allows for a smaller memory
footprint and faster caching. Example impact on rendering static
labels.

                                     │  before.txt  │             after.txt              │
                                     │    sec/op    │   sec/op     vs base               │
LabelStatic/1000runes-RTL-arabic-32     98.08µ ± 3%   88.17µ ± 1%  -10.10% (p=0.002 n=6)
LabelStatic/1000runes-RTL-complex-32    103.9µ ± 2%   101.9µ ± 1%   -1.84% (p=0.009 n=6)
LabelStatic/1000runes-RTL-emoji-32      113.3µ ± 2%   100.7µ ± 3%  -11.11% (p=0.002 n=6)
LabelStatic/1000runes-RTL-latin-32     100.01µ ± 1%   92.31µ ± 1%   -7.69% (p=0.002 n=6)
LabelStatic/1000runes-LTR-arabic-32     97.90µ ± 2%   87.92µ ± 2%  -10.19% (p=0.002 n=6)
LabelStatic/1000runes-LTR-complex-32   102.63µ ± 2%   99.81µ ± 1%   -2.75% (p=0.002 n=6)
LabelStatic/1000runes-LTR-emoji-32     106.56µ ± 2%   98.47µ ± 1%   -7.59% (p=0.002 n=6)
LabelStatic/1000runes-LTR-latin-32      97.51µ ± 1%   92.60µ ± 3%   -5.03% (p=0.002 n=6)
geomean                                 102.4µ        95.09µ        -7.10%

Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2023-11-09 15:18:46 -05:00
Elias Naur d078bf0ed7 app: unexport NewDisplayLink
It's not supposed to be used outside of package app.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-31 17:56:24 -05:00
Elias Naur ea58aacde2 app: [macOS] don't free nil string in ReadClipboard
Fixes: https://todo.sr.ht/~eliasnaur/gio/539
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-16 08:24:09 -05:00
Larry Clapp ae2b1f42b2 widget: Update Selectable key filter
Selectable was using a key event filter copied directly from editor.go,
but it didn't actually process all those keys. Update the filter to only
ask for the keys that Selectable actually uses.

Signed-off-by: Larry Clapp <larry@theclapp.org>
2023-10-15 10:44:39 -04:00
Elias Naur 63fea3d2bd widget: use local random source to avoid deprecated rand.Seed
This change replace the global rand use with a local source, to avoid
the recently deprecated global rand.Seed function. At the same time, the
time-dependent seeds are replaced with static numbers to ensure
reproducible benchmarks numbers.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-14 15:45:58 -05:00
Elias Naur ce8475a0b9 Revert "app: [Wayland] avoid a race on the send side of the wakeup pipe"
This reverts commit 7fde80e805, because
Wakeup can no longer be called after the window has been destroyed.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-14 10:02:31 -05:00
Elias Naur 37717d0df9 app: [API] replace events channel with an iterator interface
The goroutine started by Window.run runs concurrently with the user
goroutine receiving from Window.Events, leading to races such as #543.
This change replaces the Window.run goroutine and the Window.Events
channel with an iterator API driven by the user goroutine directly.

Fixes: https://todo.sr.ht/~eliasnaur/gio/543
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-14 10:02:28 -05:00
Elias Naur 7550d85447 .builds: remove unused Chrome
Chrome was required when gogio was part of the repository. It is no
longer.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-08 18:46:52 -05:00
Elias Naur c756986d9e gesture: [API] rename gesture state update methods to Update
Change the gesture state update methods to align with the convention.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-08 12:37:12 -05:00
Elias Naur dc170033cd io/router: [API] drop extra frame
This change removes the extra frame scheduled when events was delivered
during a frame. This extra frame was intended to paper over state changes
that happen later than the layout depending on it.

However, it is better for programs to never allow such state change skew,
and recent changes allows them to refresh and query state before layout.

This is an API change because programs may rely on the extra frames.
Those programs should ensure that state is updated before relying on it
in layout.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 20:04:32 -05:00
Elias Naur d42dae73f0 widget: [API] separate Float state update; remove min, max, invert parameters
This change allows users of Float to determine its state before Layout
by calling Update.

While here, remove the value transformation represented by the min, max,
invert parameters; they're too many arguments for a computation that
may as well be done by the user.

Remove Float.Pos; it is better to compute its value from the dimensions
returned by Float.Layout.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 20:04:32 -05:00
Elias Naur 23e44292bb widget: [API] separate state changes from Draggable.Layout to Update
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 20:04:32 -05:00
Elias Naur fe85136f99 widget: [API] move Enum state update to Changed, rename it to Update
Similar to an earlier change for other widgets, this change separate
Enum state changes for access earlier than Layout.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 20:04:32 -05:00
Elias Naur b9837def5c widget: [API] move Decorations state update to Actions
Similar to a previous change for Clickable and Bool this change separates
state changes from Decorations.Layout to Actions so that access may
happen before Layout.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 20:04:13 -05:00
Elias Naur dc97871122 widget: [API] rename Bool.Changed to Update and move state update to it
Similar to a previous change for Clickable, this change separates Bool
state changes to its renamed method Update. This allows access to
the most recent state before calling Layout.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 20:03:25 -05:00
Elias Naur 4a4fe5a69b widget: [API] move Clickable state update from Layout to Clicks
Before this change, Clickable state updates would happen in Layout.
However, that is too late in cases where clicks affects layout that
contiains the Clickable.

This change removes state changes from Layout and moves them to Clicks,
to allow users pre-layout access. Note that Layout itself processes
events, which means users can no longer access clicks after Layout.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 20:03:22 -05:00
Elias Naur 1686874d07 gesture: [API] rename ClickType to ClickKind
"Kind" is the Go idiomatic name for distinguishing structs outside of
the type system.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 19:11:11 -05:00
Elias Naur 650ccea28d io/pointer: [API] rename PointerEvent.Type to Kind
Kind is the idiomatic field name for distinguishing a struct without
using separate types.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 19:11:08 -05:00
Elias Naur e1b3928819 io/semantic: [API] replace DisabledOp with EnabledOp
The double-negative DisabledOp is harder to understand than a
straightforward EnabledOp. Note that the absence of an EnabledOp
implies still means that the widget is enabled.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-06 18:08:52 -05:00
Elias Naur b66dcc436c app: [macOS] fix transition from maximized to restored
The NSWindow.zoomed property is not reliable when a window is being
constructed. Only call it when necessary.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-02 18:48:18 -05:00
Elias Naur 526db27c75 gpu: fix opacity layer rendering on OpenGL
Fixes: https://todo.sr.ht/~eliasnaur/gio/536
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-10-02 13:07:15 -05:00
Chris Waldon 27193ae8e8 op/clip: prevent no-op path segments
This commit prevents the insertion of LineTo and QuadTo path segments that have
no visible effect on the path (because the path's pen is already at their end state).
This eliminates whisker artifacts from some stroked paths. Thanks to Morlay for the
bug report leading to this fix.

Fixes: https://todo.sr.ht/~eliasnaur/gio/535
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-09-18 09:28:11 -05:00
Elias Naur 313c488ec3 app: [Windows] remove padding from maximized custom decorated windows
As described in https://devblogs.microsoft.com/oldnewthing/20150304-00/?p=44543
Windows extends maximized windows outside the visible display. This is
not appropriate for custom decorated windows, so this change implements
a workaround in the handling of WM_NCCALCSIZE.

While here, replace the deltas field from window state to fix issues
when switching between decoration modes.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-09-08 15:22:40 -05:00
Elias Naur f30e936d9a app: [Windows] remove redundant call to SetWindowText
And fix a typo while here.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-09-08 15:12:47 -05:00
Elias Naur ae3bd2a1e1 op/paint: add opacity operation
The new paint.PushOpacity allows for adjusting the opacity of a group
of drawing operations.

Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-09-08 11:46:17 -05:00
Elias Naur ae43d18ced internal/ops: remove unused TypePushTransform
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-09-07 14:48:11 -05:00
Dominik Honnef b4d93379c4 op: don't allocate for each string reference
When storing a string in an interface value that escapes, Go has to heap
allocate space for the string header, as interface values can only store
pointers. In text-heavy applications, this can lead to hundreds of
allocations per frame due to semantic.LabelOp, the primary user of
string-typed references in ops.

Instead of allocating each string header individually, provide a slice
of strings to store string-typed references in, and store pointers into
this slice as the actual references. This only allocates when resizing
the slice's backing array, and averages out to no allocations, as the
backing array gets reused between calls to Ops.Reset.

We introduce two new functions, Write1String and Write2String, which
make use of this new slice for their last argument. We could've
automated this in the existing Write1 and Write2 methods, but that would
require type assertions on each call, and the vast majority of ops do
not make use of strings.

Signed-off-by: Dominik Honnef <dominik@honnef.co>
2023-09-02 09:02:39 -06:00
Dominik Honnef b9654eb4eb app: support numpad keys in xkb
Signed-off-by: Dominik Honnef <dominik@honnef.co>
2023-09-01 16:34:16 -06:00
Dominik Honnef 89d20c7d99 app: [macOS] handle mouse dragging with buttons other than the left one
Signed-off-by: Dominik Honnef <dominik@honnef.co>
2023-08-31 15:52:44 -04:00
Dominik Honnef 14bab8efae app: [macOS] handle middle mouse button correctly
NSView only has events for left, right, and other. Also, the Go side
wasn't actually checking for buttons other than left and right.

Signed-off-by: Dominik Honnef <dominik@honnef.co>
2023-08-31 15:52:39 -04:00
Chris Waldon f437aaf359 io/router: fix semantic area traversal
This commit updates the logic behind SemanticAt to use the same hit area
traversal as normal event routing, which should result in more accurate
results for screen readers trying to resolve widgets that might be partially
obscured by non-semantic content.

While here, I realized that the iteration of hit areas needed to stop at
the first matching semantic area, and I added that capability and updated
the ActionAt logic to leverage it as well.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-31 15:09:05 -04:00
Elias Naur cf5ae4aad9 internal/egl: call eglTerminate after context release
Without eglTerminate, using EGL will crash or report spurious errors after
creating and destroying enough contexts. The test program in #528
takes 5-10 window cycles before errors show up for me.

Fixes: https://todo.sr.ht/~eliasnaur/gio/528
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-08-23 13:31:31 -06:00
Chris Waldon 8679f49fff text: [API] be absolutely consistent about newline truncation
This commit changes the shaper's behavior when truncating text. Previously, if the final
line allowed by MaxLines ended with a newline, whether or not that newline was truncated
depended upon whether we knew that there was more text after the current paragraph. However,
this makes reasoning about what the shaper will do quite difficult. It seems better to be
consistent. Now we will insert a truncator at the end of the final line if it has a trailing
newline character, regardless of whether it ends the input text.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-22 16:37:24 -06:00
Chris Waldon 83202263b9 text: simplify truncation accounting
This commit reverts the work of several previous attempts to resolve truncation-related
rune accounting problems and adopts a simpler approach. Instead of taking a special codepath
when shaping only a newline, we shape the empty string to get its line metrics. Instead of
modifying the final glyph conditionally to account for runes we never actually shaped, we
track that count on the document type and handle it withing the NextGlyph method.

These changes result in much simpler code, and resolve a real bug. We were accidentally corrupting
cached paragraphs when doing the truncation post-processing in Shaper.layoutText. The modification
made to the final glyph there actually did modify the cached copy, which would then be reused when
that string was shaped again (even if there were a different number of truncated runes after it).

This changeset ensures that the cached copy of a paragraph is never modified.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-22 16:37:20 -06:00
Elias Naur 7fde80e805 app: [Wayland] avoid a race on the send side of the wakeup pipe
Discovered while debugging #528 with -race.

References: https://todo.sr.ht/~eliasnaur/gio/528
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-08-22 16:33:12 -06:00
Elias Naur e9d0619641 app: [macOS] stop display after any events that may access it
Fixes: https://todo.sr.ht/~eliasnaur/gio/527
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-08-22 11:57:43 -06:00
Elias Naur 2e524200ab app: [macOS] fix display link callback race
Commit c0c25b777 replaced the synchronizing of the display link callback
from a sync.Map to a cgo.Handle. However, the change didn't take into
account the lifecycle issues: a callback may happen just as the cgo.Handle
is freed, leading to a misuse crash.

This change restores the sync.Map synchronization, which avoids the
lifecycle issue.

Fixes: https://todo.sr.ht/~eliasnaur/gio/526
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-08-22 11:02:49 -06:00
Chris Waldon cc477e9ca6 app: [Windows] ensure custom window decorations allow resize
This commit fixes a platform inconsistency that prevented custom-decorated windows
from being resizable on edges where their custom decorations placed a draggable
system.ActionInputOp.

The prior behavior always checked for this action type before
checking if the cursor was potentially in a window resize area, which meant that
for windows with material.Decorations, it was impossible to resize those windows
from their top edge. The system.ActionMove handler would always win. This is not
the case on platforms like macOS, so this commit makes the behavior consistent by
prioritizing resize over drag.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-22 09:08:06 -06:00
Veikko Sariola 290b5fe821 widget: click button only if key pressed and released
This commit fixes the non-intuitive behaviour, where hitting return or
space with a button focused, then tabbing to another button and
releasing the key causes the second button to trigger. It feels wrong,
as the "gesture" was never initiated on the second button. The fix makes
widget.Clickable track which key was pressed, in a variable called
pressedKey, and only considers a key release if the released key matches
the pressed key. Finally, if the widget loses focus, pressedKey is
cleared.

Fixes: https://todo.sr.ht/~eliasnaur/gio/525
Signed-off-by: Veikko Sariola <5684185+vsariola@users.noreply.github.com>
2023-08-22 09:07:48 -06:00
Chris Waldon e9cb0b326d io/router: fix system action routing logic
When running ActionAt, the router used to only consider the topmost clip area, even
if that clip area had no input handlers attached whatsoever. This change updates the
logic for that test to use the same traversal as normal event handling, ensuring that
action inputs behave intuitively like any other pointer input area. Included is a test
catching the problematic behavior that prompted this change.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-21 10:44:44 -06:00
Elias Naur 0e77a2b521 app: [Windows] enable drop shadows for custom decorated windows
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-08-16 15:44:04 -06:00
Elias Naur 63550cc81e app: [Windows] make custom decorated windows behave like regular windows
Signed-off-by: Elias Naur <mail@eliasnaur.com>
2023-08-16 15:44:04 -06:00
Chris Waldon 03c21dc1b5 text: add android portability notice to NewShaper
NewShaper cannot be called prior to opening an application window on Android unless
the application does not want system font support. Add a note to this effect to the
constructor.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-12 08:16:28 -06:00
Chris Waldon 05f0dc2513 text: ensure truncated consecutive newlines are handled
This commit ensures that multiple newlines in a row still produce expected
results when occuring within a truncated string. The problem was that we usually
wrap text that is truncated in a way that forces the truncator symbol to appear
at the end *unless* we know we're on the final paragraph of the input text. This
is the right behavior for text that will be displayed, but when shaping a paragraph
containing nothing but a newline, we do not want the truncator symbol in our line.
I simply had to disable the forced truncation contextually to make it work.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-03 15:57:34 -04:00
Chris Waldon c1d975cced go.*: update go-text for empty string fix
This commit updates us to a version of go-text that correctly provides text
dimensions for the empty string when laying it out with width zero. Previously,
zero width would result in text with no height.

Fixes: https://todo.sr.ht/~eliasnaur/gio/518
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-03 07:58:28 -04:00
Chris Waldon 32f15ede7b text: fix additional truncated newline bug
This commit fixes another rune accounting bug that the fuzzer discovered. If we
shaped a space in order to acquire line metrics, but the space itself was truncated,
we would reset the truncated count to zero. This had the side effect of lying to
later logic about whether the truncator run was present at the end of the shaped
text.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-02 12:18:04 -04:00
Chris Waldon d414116990 text: update fuzzer to sometimes truncate
This commit updates the shaper fuzzer to try truncating the text, exposing
new edge cases.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-02 10:44:15 -04:00
Chris Waldon 341978dbcd text: fix zero-width truncated newline rune accounting
This commit fixes another rune accounting issue that only existed when shaping
a solitary newline with zero width while truncating the line.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-02 10:32:14 -04:00
Chris Waldon 80da4d6b02 text: fix EOF detection at newline boundaries
This commit tests and fixes some edge cases that threw off rune accounting
when a newline character was the final rune in the input *and* the text was
being truncated. I imagine they were never previously reported because it's
rare to try to truncate such text.

Thanks to Dominik Honnef for the report and reproducer.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-02 09:44:41 -04:00
Chris Waldon edbf872b44 widget: fix label vertical glyph padding logic
We previously were not handling glyphs that extended vertically beyond the
ascent/descent declared by their font. This is done rarely with text fonts,
but is apparently common among symbol and emoji fonts.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-08-01 09:09:10 +02:00
Chris Waldon c7c49c3258 text: drop unused line.bounds
This commit removes the logic that calculates the bounding box of a line.
We don't actually use this information anywhere, so computing it is just
a waste of CPU and memory. Widgets arrive at their own bounding boxes from
consuming the glyph stream anyway.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-31 12:07:37 +02:00
Chris Waldon fdd102aaf9 widget: simplify and improve cursor position generation
This commit updates the strategy of our cursor positioning index to eliminate
cursor positions *after* trailing whitespace characters on a line. Eliminating
such cursor positions enables us to collapse trailing whitespace visually without
impacting the editability of text (this will be done in a future commit).

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-31 12:05:07 +02:00
Chris Waldon 8dc03ed655 text,widget: remove fractional line height
The previous logic kept the y offset of a line as a fractional value
until the last possible moment in an effort to be as true to a fractional
line height as possible (minimize the error), but this interacts pathologically
with multi-line text selections, as the selections may have visibly different
gaps between lines. It's better to always shift lines by a fixed quantity of
whole pixels, even if it is technically less accurate to the desired line height.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-31 12:03:18 +02:00
Chris Waldon 1d8b54892a text: commit important fuzz failure test data
This commit adds several notable fuzz test failure cases to our corpus.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-31 12:00:55 +02:00
Chris Waldon 7966832536 go.*: update go-text
This commit updates our version of go-text to pick up important bugfixes to the
line wrapper (fixing some fuzzer-discovered bugs).

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-31 12:00:51 +02:00
Chris Waldon 36a39f7d38 text: handle shaping string containing only newline
This commit ensures that we properly handle the case in which an input string is only
a newline character. We now make a run of text by shaping a space rune and then
drop the glyph/rune data from the space (keeping the line height and such).
The prior behavior would shape zero runes, resulting in no output runs, and thus our
logic for synthesizing a glyph for the newline would never execute while iterating the
runs.

I tried to restructure to instead catch whether there were zero runs after the iteration,
but it came out much uglier and harder to understand that way.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-31 12:00:45 +02:00
Egon Elbre d62057a62e app: fix windows build
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
2023-07-19 13:31:55 +02:00
Chris Waldon ddf770b9d5 widget{,/material}: surface line height manipulation
This commit surfaces fields to manipulate the line height of all label and editor
types. It's unfortunate how this spreads through the API, but I don't see a good
way to eliminate that right now.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 10:05:33 +02:00
Chris Waldon acab582487 widget/material: allow configuring default typeface on theme
This commit introduces the material.Theme.Face field, which will automatically
populate the Font.Typeface in every text widget created using a constructor function
in package material.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 10:02:18 +02:00
Chris Waldon 6384ab6087 text: add family DSL parser
This commit adds a parser for a simple domain-specific language that
can express a comma-delimited list of font families within a string.

I chose to encode families in this way because the string can be used
as an efficient hash key in a way that a slice of families cannot. Similarly,
using a slice of families would require allocations on the caller side.

The particular format was chosen to allow lists to be written with as little
fanfare as possible. This is why quotation marks are completely optional. It's
easy to read:

  Times New Roman, Georgia, serif

Why force the user to type this (this will parse the same):

  "Times New Roman", "Georgia", "serif"

I've tried to handle edge cases exhaustively. Commas are legal within quotes.
Within a quoted string, you can escape instances of the surrounding quote with
a backslash, and can escape literal backslashes by adding another backslash.

I wrote the lexer/parser by hand, and I hope that they're both easy to understand
and (if need be) extend.

A side effect of the DSL I've chosen (and part of my reasoning for allowing both
single and double quoted strings) is that CSS font-family rules will generally be
valid font family lists in Gio. This means the syntax is already familiar to users
coming from other technologies, and that you can copy from a web-based application
to get a similar font stack in Gio.

Fixes: https://todo.sr.ht/~eliasnaur/gio/317
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 10:01:51 +02:00
Chris Waldon 43c47f0883 go.*,text,font{,/opentype},app,gpu,widget{,/material}: [API] load system fonts
This commit updates the text package to be able to load system fonts. As a consequence,
application authors may choose to provide no fonts manually, and it's
also possible that the system provides none (WASM, for instance, currently provides no
system fonts). As such, the text stack needed some minor tweaks to handle this case by
displaying blank spaces where text should be rather than crashing when no faces are
available.

Internally, we are dropping the old method of choosing faces and instead relying solely
on the new font matching logic in go-text. I chose to do this because maintaining two
different sets of logic with a hierarchical relationship proved to be really complex,
and also the go-text logic seems to produce higher-quality choices.

The breaking API change from this commit is the new way of constructing a text shaper
using text.ShaperOptions. Providing no options will result in a shaper that uses solely
system fonts. The various options can be used to disable system font loading and to
provide an already-parsed collection of fonts as per Gio's old API.

The material.NewTheme function now accepts no arguments instead of a font collection.
Users wanting to provide a collection can simply provide a new shaper configured how
they would like:

    theme := material.NewTheme()
	theme.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Regular()))

This commit touches many packages to fix up their construction of text shapers, mostly in
test code. The changes to the tests in package widget deserve special note:
Changing our font resolution logic caused the tofu characters within the
test strings to use a different font's tofu. This isn't a problem, but shifted
the layout of the shaped text a little bit. I've updated the numbers to expect
the new glyph positions.

Fixes: https://todo.sr.ht/~eliasnaur/gio/309
Fixes: https://todo.sr.ht/~eliasnaur/gio/184
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 10:01:51 +02:00
Chris Waldon babe7a292b app,internal/debug: define GIODEBUG env var
This commit defines an environment-variable-based debug mechanism allowing
users to toggle various debug features of their applications at runtime. The
only currently supported features are debug logging in the text stack and
suppressing the usage message that would otherwise be printed if you supplied
a malformed GIODEBUG value. The syntax is a comma-delimited list of features
right now. To see the usage, set the variable to the empty string (or any other
unsupported value):

$ GIODEBUG="" go run .

To suppress the usage message, use GIODEBUG=silent. This may be helpful for scripts
trying to activate debug features and inspect their output across versions of Gio
with different debug options available.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 09:54:27 +02:00
Chris Waldon 92bc52c25c app: [Android] ensure data dirs are set by window init
This commit alters the android backend to automatically populate some environment
variables as early as possible in application startup. Specifically, this commit
sets the XDG_{CONFIG,CACHE}_HOME environment variables which are necessary for
the text shaper to infer a valid cache file location.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 09:52:09 +02:00
Chris Waldon df782ea7c5 go.mod,.builds/*: update to Go 1.19
We only support the most recent two go versions, and using 1.18 prevents use of
atomic.Bool, failing CI for a different patchset of mine.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-19 09:50:34 +02:00
inkeliz 74a87b1092 app/io: [android,js] add password keyboard hint
Fixes: https://todo.sr.ht/~eliasnaur/gio/517

Signed-off-by: inkeliz <inkeliz@inkeliz.com>
2023-07-17 22:49:44 +02:00
Chris Waldon 6ea4119a3c text,widget: [API] implement consistent, controllable line height
This commit ensures that any given paragraph of text shaped by Gio will use a single
internal line height. This line height is determined (by default) by the text size,
rather than the fonts involved. This is a breaking change, as previously we would
blindly use the largest line height of any font in a line for that line, leading to
lines within the same paragraph with extremely uneven spacing. This commit also
updates some test expectations in package widget.

I thought pretty hard about how to implement line spacing, and consulted a few sources:

[0] https://www.figma.com/blog/line-height-changes/
[1] https://practicaltypography.com/line-spacing.html
[2] https://developer.mozilla.org/en-US/docs/Web/CSS/line-height

There is no single, universal way to think about line spacing. Fonts internally specify
a line height as the sum of their ascent, descent, and gap, but the line height of two
fonts at the same pixel size (say 20 Sp) can vary wildy (especially across writing systems).
There are two strategies we could pursue to establish the line height of a paragraph of text:

- derive the line height from the fonts involved (our old behavior, and the behavior of
  many word processors)
- derive the line height from the requested text size provided by the user (the behavior of the
  web).

The challenge with the first option is that for a given piece of text in the UI, there can
be a silly number of fonts involved. If a label dispays user-generated content, the user can
put an emoji in it, and emoji fonts have different line heights from latin ones. This can cause
unexpected and nasty layout shift. Gio would previously do exactly this, on a line-by-line basis,
resulting in unevenly spaced lines within a paragraph depending on which fonts were used on
which lines. Choosing one of the fonts and enforcing its line height would make things consistent,
but it isn't clear how to choose that canonical font. There is no 1:1 mapping between the input
text.Font provided in the shaping parameters and a single font.Face. Instead, that mapping depends
upon the runes being shaped.

I think the only sane way to implement the first option would be to synthesize some text in the
provided system.Locale (mapping the language to a script and then generating a rune from that
script), shape that single rune, and then enforce the line height of the resulting face on the
entire paragraph. This would require doing a fair bit more work per paragraph than Gio does today,
so I've opted not to do it.

Instead, the second option allows us to choose a line height based on the size of the text that
the user wants to display. While this can potentially interact poorly with unusually tall fonts,
it means that text will always have a consistent line height.

I've provided two knobs to control line height:

- text.Parameters.LineHeight lets you set a specific height in pixels with a default value of
  text.Parameters.PxPerEm.
- text.Parameters.LineHeightScale applies a scaling factor to the LineHeight, allowing you to
  easily space out text without hard-coding a specific pixel size. The default value here
  (drawn from the recommendations of [1]) is 1.2, which looks pretty good across many fonts.

I've chosen this two-value API because many users will want to set one or the other value. I
considered instead a single value field and a "mode" that would specify how it was used, but
that felt uglier. Also, you *can* set both of these two fields and get predictable results.

I'd like to revisit using the line height of the chosen fonts in the future, but it seems a
little too complex to be worthwhile right now. An interesting option would be making the
select-a-face-using-locale strategy described above an opt-in feature, though some users
might instead want to just use the tallest line height among fonts in use. Something like
this Android API might be appropriate:

[3] https://learn.microsoft.com/en-us/dotnet/api/android.widget.textview.fallbacklinespacing?view=xamarin-android-sdk-13

I'd like to thank Dominik Honnef for some good discussion around this feature, and for pointing
me to some good sources on the subject.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-17 22:33:02 +02:00
Chris Waldon 15031d0b52 font{,/{opentype,gofont}},text: [API] drop monospace font metadata
In the general case, it isn't possible for us to efficiently find system fonts that
are monospace. Fonts don't advertise being monospace frequently, so the only way to
reliably detect it is to check that all glyphs are the same width. This is expensive,
far too much so to be done on every system font when there may be thousands of them.

Other font resolution systems rely upon the user requesting fonts by their family name.
If you want a monospace font, ask for it by name or use a generic name like 'monospace'.
This will be Gio's approach from here on out.

Existing code relying upon setting Variant="Mono" should instead set Typeface="Go Mono"
(for the Go font) or specify another monospace typeface. The generic face "monospace"
will search for one of a set of known monospace fonts that may be available on the system.

Similarly, smallcaps isn't well advertised and users should rely on requesting all-smallcaps
fonts by typeface. To get the Go smallcaps font, use Typeface="Go Smallcaps".

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-17 21:25:10 +02:00
Chris Waldon 5606a961f2 text: fix bitmap y offset computation
This commit fixes a bug that would incorrectly baseline bitmap glyphs text if the line
contained another font with a taller line height. The logic for computing the y offset of
the glyph incorrectly assumed that the Glyph.Ascent was particular to the glyph instead of
the line. I've updated it to use a glyph-specific value.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
2023-07-17 21:20:36 +02:00
110 changed files with 3297 additions and 1692 deletions
+17 -7
View File
@@ -8,23 +8,28 @@ packages:
- libxml2-dev
- libssl-dev
- libz-dev
- llvm-dev # for cctools
- uuid-dev ## for cctools
- llvm-dev # cctools
- uuid-dev # cctools
- ninja-build # cctools
- systemtap-sdt-dev # cctools
- libbsd-dev # cctools
- linux-libc-dev # cctools
- libplist-utils # for gogio
sources:
- https://git.sr.ht/~eliasnaur/applesdks
- https://git.sr.ht/~eliasnaur/gio
- https://git.sr.ht/~eliasnaur/giouiorg
- https://github.com/tpoechtrager/cctools-port.git
- https://github.com/tpoechtrager/apple-libtapi.git
- https://github.com/mackyle/xar.git
- https://github.com/tpoechtrager/cctools-port
- https://github.com/tpoechtrager/apple-libtapi
- https://github.com/tpoechtrager/apple-libdispatch
- https://github.com/mackyle/xar
environment:
APPLE_TOOLCHAIN_ROOT: /home/build/appletools
PATH: /home/build/sdk/go/bin:/home/build/go/bin:/usr/bin
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl -s https://dl.google.com/go/go1.18.9.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
curl -s https://dl.google.com/go/go1.19.11.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
- prepare_toolchain: |
mkdir -p $APPLE_TOOLCHAIN_ROOT
cd $APPLE_TOOLCHAIN_ROOT
@@ -42,6 +47,11 @@ tasks:
- install_appletoolchain: |
cd giouiorg
go build -o $APPLE_TOOLCHAIN_ROOT/tools ./cmd/appletoolchain
- build_libdispatch: |
cd apple-libdispatch
cmake -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_INSTALL_PREFIX=$APPLE_TOOLCHAIN_ROOT/libdispatch .
ninja
ninja install
- build_xar: |
cd xar/xar
ac_cv_lib_crypto_OpenSSL_add_all_ciphers=yes CC=clang ./autogen.sh --prefix=/usr
@@ -53,7 +63,7 @@ tasks:
./install.sh
- build_cctools: |
cd cctools-port/cctools
./configure --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --target=x86_64-apple-darwin19
./configure --target=x86_64-apple-darwin19 --prefix $APPLE_TOOLCHAIN_ROOT/toolchain --with-libtapi=$APPLE_TOOLCHAIN_ROOT/libtapi --with-libdispatch=$APPLE_TOOLCHAIN_ROOT/libdispatch --with-libblocksruntime=$APPLE_TOOLCHAIN_ROOT/libdispatch
make install
- test_macos: |
cd gio
+1 -1
View File
@@ -16,7 +16,7 @@ environment:
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl https://dl.google.com/go/go1.18.9.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
curl https://dl.google.com/go/go1.19.11.freebsd-amd64.tar.gz | tar -C /home/build/sdk -xzf -
- test_gio: |
cd gio
go test ./...
+1 -6
View File
@@ -40,7 +40,7 @@ secrets:
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl -s https://dl.google.com/go/go1.18.9.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
curl -s https://dl.google.com/go/go1.19.11.linux-amd64.tar.gz | tar -C /home/build/sdk -xzf -
- check_gofmt: |
cd gio
test -z "$(gofmt -s -l .)"
@@ -67,11 +67,6 @@ tasks:
CGO_ENABLED=1 GOARCH=386 go test ./...
GOOS=windows go test -exec=wine ./...
GOOS=js GOARCH=wasm go build -o /dev/null ./...
- install_chrome: |
curl -s https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
sudo sh -c 'echo "deb [arch=amd64] https://dl-ssl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
sudo apt-get -qq update
sudo apt-get -qq install -y google-chrome-stable
- install_jdk8: |
curl -so jdk.deb "https://cdn.azul.com/zulu/bin/zulu8.42.0.21-ca-jdk8.0.232-linux_amd64.deb"
sudo apt-get -qq install -y -f ./jdk.deb
+1 -1
View File
@@ -10,7 +10,7 @@ environment:
tasks:
- install_go: |
mkdir -p /home/build/sdk
curl https://dl.google.com/go/go1.18.9.src.tar.gz | tar -C /home/build/sdk -xzf -
curl https://dl.google.com/go/go1.19.11.src.tar.gz | tar -C /home/build/sdk -xzf -
cd /home/build/sdk/go/src
./make.bash
- test_gio: |
+8 -8
View File
@@ -20,7 +20,7 @@ type d3d11Context struct {
width, height int
}
const debug = false
const debugDirectX = false
func init() {
drivers = append(drivers, gpuAPI{
@@ -28,7 +28,7 @@ func init() {
initializer: func(w *window) (context, error) {
hwnd, _, _ := w.HWND()
var flags uint32
if debug {
if debugDirectX {
flags |= d3d11.CREATE_DEVICE_DEBUG
}
dev, ctx, _, err := d3d11.CreateDevice(
@@ -60,10 +60,10 @@ func (c *d3d11Context) RenderTarget() (gpu.RenderTarget, error) {
}
func (c *d3d11Context) Present() error {
err := c.swchain.Present(1, 0)
if err == nil {
return nil
}
return wrapErr(c.swchain.Present(1, 0))
}
func wrapErr(err error) error {
if err, ok := err.(d3d11.ErrorCode); ok {
switch err.Code {
case d3d11.DXGI_STATUS_OCCLUDED:
@@ -84,7 +84,7 @@ func (c *d3d11Context) Refresh() error {
}
c.releaseFBO()
if err := c.swchain.ResizeBuffers(0, 0, 0, d3d11.DXGI_FORMAT_UNKNOWN, 0); err != nil {
return err
return wrapErr(err)
}
c.width = width
c.height = height
@@ -122,7 +122,7 @@ func (c *d3d11Context) Release() {
d3d11.IUnknownRelease(unsafe.Pointer(c.dev), c.dev.Vtbl.Release)
}
*c = d3d11Context{}
if debug {
if debugDirectX {
d3d11.ReportLiveObjects()
}
}
+6 -5
View File
@@ -12,16 +12,16 @@ Create a new Window by calling NewWindow. On mobile platforms or when Gio
is embedded in another project, NewWindow merely connects with a previously
created window.
A Window is run by receiving events from its Events channel. The most
important event is FrameEvent that prompts an update of the window
contents and state.
A Window is run by calling NextEvent in a loop. The most important event is
FrameEvent that prompts an update of the window contents.
For example:
import "gioui.org/unit"
w := app.NewWindow()
for e := range w.Events() {
for {
e := w.NextEvent()
if e, ok := e.(system.FrameEvent); ok {
ops.Reset()
// Add operations to ops.
@@ -50,7 +50,8 @@ For example, to display a blank but otherwise functional window:
func main() {
go func() {
w := app.NewWindow()
for range w.Events() {
for {
w.NextEvent()
}
}()
app.Main()
+1 -1
View File
@@ -29,7 +29,7 @@ func FuzzIME(f *testing.F) {
f.Add([]byte("20007800002\x02000"))
f.Add([]byte("200A02000990\x19002\x17\x0200"))
f.Fuzz(func(t *testing.T, cmds []byte) {
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.WithCollection(gofont.Collection()))
e := new(widget.Editor)
e.Focus()
+41
View File
@@ -47,6 +47,13 @@ type WndClassEx struct {
HIconSm syscall.Handle
}
type Margins struct {
CxLeftWidth int32
CxRightWidth int32
CyTopHeight int32
CyBottomHeight int32
}
type Msg struct {
Hwnd syscall.Handle
Message uint32
@@ -69,6 +76,21 @@ type MinMaxInfo struct {
PtMaxTrackSize Point
}
type NCCalcSizeParams struct {
Rgrc [3]Rect
LpPos *WindowPos
}
type WindowPos struct {
HWND syscall.Handle
HWNDInsertAfter syscall.Handle
x int32
y int32
cx int32
cy int32
flags uint32
}
type WindowPlacement struct {
length uint32
flags uint32
@@ -245,6 +267,7 @@ const (
WM_MOUSEHWHEEL = 0x020E
WM_NCACTIVATE = 0x0086
WM_NCHITTEST = 0x0084
WM_NCCALCSIZE = 0x0083
WM_PAINT = 0x000F
WM_QUIT = 0x0012
WM_SETCURSOR = 0x0020
@@ -323,6 +346,7 @@ var (
_DispatchMessage = user32.NewProc("DispatchMessageW")
_EmptyClipboard = user32.NewProc("EmptyClipboard")
_GetWindowRect = user32.NewProc("GetWindowRect")
_GetClientRect = user32.NewProc("GetClientRect")
_GetClipboardData = user32.NewProc("GetClipboardData")
_GetDC = user32.NewProc("GetDC")
_GetDpiForWindow = user32.NewProc("GetDpiForWindow")
@@ -379,6 +403,9 @@ var (
_ImmReleaseContext = imm32.NewProc("ImmReleaseContext")
_ImmSetCandidateWindow = imm32.NewProc("ImmSetCandidateWindow")
_ImmSetCompositionWindow = imm32.NewProc("ImmSetCompositionWindow")
dwmapi = syscall.NewLazySystemDLL("dwmapi")
_DwmExtendFrameIntoClientArea = dwmapi.NewProc("DwmExtendFrameIntoClientArea")
)
func AdjustWindowRectEx(r *Rect, dwStyle uint32, bMenu int, dwExStyle uint32) {
@@ -430,6 +457,14 @@ func DispatchMessage(m *Msg) {
_DispatchMessage.Call(uintptr(unsafe.Pointer(m)))
}
func DwmExtendFrameIntoClientArea(hwnd syscall.Handle, margins Margins) error {
r, _, _ := _DwmExtendFrameIntoClientArea.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&margins)))
if r != 0 {
return fmt.Errorf("DwmExtendFrameIntoClientArea: %#x", r)
}
return nil
}
func EmptyClipboard() error {
r, _, err := _EmptyClipboard.Call()
if r == 0 {
@@ -444,6 +479,12 @@ func GetWindowRect(hwnd syscall.Handle) Rect {
return r
}
func GetClientRect(hwnd syscall.Handle) Rect {
var r Rect
_GetClientRect.Call(uintptr(hwnd), uintptr(unsafe.Pointer(&r)))
return r
}
func GetClipboardData(format uint32) (syscall.Handle, error) {
r, _, err := _GetClipboardData.Call(uintptr(format))
if r == 0 {
+63 -4
View File
@@ -242,6 +242,9 @@ func convertKeysym(s C.xkb_keysym_t) (string, bool) {
if 'a' <= s && s <= 'z' {
return string(rune(s - 'a' + 'A')), true
}
if C.XKB_KEY_KP_0 <= s && s <= C.XKB_KEY_KP_9 {
return string(rune(s - C.XKB_KEY_KP_0 + '0')), true
}
if ' ' < s && s <= '~' {
return string(rune(s)), true
}
@@ -255,8 +258,6 @@ func convertKeysym(s C.xkb_keysym_t) (string, bool) {
n = key.NameRightArrow
case C.XKB_KEY_Return:
n = key.NameReturn
case C.XKB_KEY_KP_Enter:
n = key.NameEnter
case C.XKB_KEY_Up:
n = key.NameUpArrow
case C.XKB_KEY_Down:
@@ -297,9 +298,9 @@ func convertKeysym(s C.xkb_keysym_t) (string, bool) {
n = key.NameF11
case C.XKB_KEY_F12:
n = key.NameF12
case C.XKB_KEY_Tab, C.XKB_KEY_KP_Tab, C.XKB_KEY_ISO_Left_Tab:
case C.XKB_KEY_Tab, C.XKB_KEY_ISO_Left_Tab:
n = key.NameTab
case 0x20, C.XKB_KEY_KP_Space:
case 0x20:
n = key.NameSpace
case C.XKB_KEY_Control_L, C.XKB_KEY_Control_R:
n = key.NameCtrl
@@ -309,6 +310,64 @@ func convertKeysym(s C.xkb_keysym_t) (string, bool) {
n = key.NameAlt
case C.XKB_KEY_Super_L, C.XKB_KEY_Super_R:
n = key.NameSuper
case C.XKB_KEY_KP_Space:
n = key.NameSpace
case C.XKB_KEY_KP_Tab:
n = key.NameTab
case C.XKB_KEY_KP_Enter:
n = key.NameEnter
case C.XKB_KEY_KP_F1:
n = key.NameF1
case C.XKB_KEY_KP_F2:
n = key.NameF2
case C.XKB_KEY_KP_F3:
n = key.NameF3
case C.XKB_KEY_KP_F4:
n = key.NameF4
case C.XKB_KEY_KP_Home:
n = key.NameHome
case C.XKB_KEY_KP_Left:
n = key.NameLeftArrow
case C.XKB_KEY_KP_Up:
n = key.NameUpArrow
case C.XKB_KEY_KP_Right:
n = key.NameRightArrow
case C.XKB_KEY_KP_Down:
n = key.NameDownArrow
case C.XKB_KEY_KP_Prior:
// not supported
return "", false
case C.XKB_KEY_KP_Next:
// not supported
return "", false
case C.XKB_KEY_KP_End:
n = key.NameEnd
case C.XKB_KEY_KP_Begin:
n = key.NameHome
case C.XKB_KEY_KP_Insert:
// not supported
return "", false
case C.XKB_KEY_KP_Delete:
n = key.NameDeleteForward
case C.XKB_KEY_KP_Multiply:
n = "*"
case C.XKB_KEY_KP_Add:
n = "+"
case C.XKB_KEY_KP_Separator:
// not supported
return "", false
case C.XKB_KEY_KP_Subtract:
n = "-"
case C.XKB_KEY_KP_Decimal:
// TODO(dh): does a German keyboard layout also translate the numpad key to XKB_KEY_KP_DECIMAL? Because in
// German, the decimal is a comma, not a period.
n = "."
case C.XKB_KEY_KP_Divide:
n = "/"
case C.XKB_KEY_KP_Equal:
n = "="
default:
return "", false
}
+26 -21
View File
@@ -338,20 +338,6 @@ func (w *window) NewContext() (context, error) {
func dataDir() (string, error) {
dataDirOnce.Do(func() {
dataPath = <-dataDirChan
// Set XDG_CACHE_HOME to make os.UserCacheDir work.
if _, exists := os.LookupEnv("XDG_CACHE_HOME"); !exists {
cachePath := filepath.Join(dataPath, "cache")
os.Setenv("XDG_CACHE_HOME", cachePath)
}
// Set XDG_CONFIG_HOME to make os.UserConfigDir work.
if _, exists := os.LookupEnv("XDG_CONFIG_HOME"); !exists {
cfgPath := filepath.Join(dataPath, "config")
os.Setenv("XDG_CONFIG_HOME", cfgPath)
}
// Set HOME to make os.UserHomeDir work.
if _, exists := os.LookupEnv("HOME"); !exists {
os.Setenv("HOME", dataPath)
}
})
return dataPath, nil
}
@@ -389,6 +375,22 @@ func Java_org_gioui_Gio_runGoMain(env *C.JNIEnv, class C.jclass, jdataDir C.jbyt
}
n := C.jni_GetArrayLength(env, jdataDir)
dataDir := C.GoStringN((*C.char)(unsafe.Pointer(dirBytes)), n)
// Set XDG_CACHE_HOME to make os.UserCacheDir work.
if _, exists := os.LookupEnv("XDG_CACHE_HOME"); !exists {
cachePath := filepath.Join(dataDir, "cache")
os.Setenv("XDG_CACHE_HOME", cachePath)
}
// Set XDG_CONFIG_HOME to make os.UserConfigDir work.
if _, exists := os.LookupEnv("XDG_CONFIG_HOME"); !exists {
cfgPath := filepath.Join(dataDir, "config")
os.Setenv("XDG_CONFIG_HOME", cfgPath)
}
// Set HOME to make os.UserHomeDir work.
if _, exists := os.LookupEnv("HOME"); !exists {
os.Setenv("HOME", dataDir)
}
dataDirChan <- dataDir
C.jni_ReleaseByteArrayElements(env, jdataDir, dirBytes)
@@ -951,18 +953,18 @@ func Java_org_gioui_GioView_onKeyEvent(env *C.JNIEnv, class C.jclass, handle C.j
//export Java_org_gioui_GioView_onTouchEvent
func Java_org_gioui_GioView_onTouchEvent(env *C.JNIEnv, class C.jclass, handle C.jlong, action, pointerID, tool C.jint, x, y, scrollX, scrollY C.jfloat, jbtns C.jint, t C.jlong) {
w := cgo.Handle(handle).Value().(*window)
var typ pointer.Type
var kind pointer.Kind
switch action {
case C.AMOTION_EVENT_ACTION_DOWN, C.AMOTION_EVENT_ACTION_POINTER_DOWN:
typ = pointer.Press
kind = pointer.Press
case C.AMOTION_EVENT_ACTION_UP, C.AMOTION_EVENT_ACTION_POINTER_UP:
typ = pointer.Release
kind = pointer.Release
case C.AMOTION_EVENT_ACTION_CANCEL:
typ = pointer.Cancel
kind = pointer.Cancel
case C.AMOTION_EVENT_ACTION_MOVE:
typ = pointer.Move
kind = pointer.Move
case C.AMOTION_EVENT_ACTION_SCROLL:
typ = pointer.Scroll
kind = pointer.Scroll
default:
return
}
@@ -992,7 +994,7 @@ func Java_org_gioui_GioView_onTouchEvent(env *C.JNIEnv, class C.jclass, handle C
return
}
w.callbacks.Event(pointer.Event{
Type: typ,
Kind: kind,
Source: src,
Buttons: btns,
PointerID: pointer.ID(pointerID),
@@ -1150,6 +1152,7 @@ func (w *window) SetInputHint(mode key.InputHint) {
TYPE_CLASS_TEXT = 1
TYPE_TEXT_VARIATION_EMAIL_ADDRESS = 32
TYPE_TEXT_VARIATION_URI = 16
TYPE_TEXT_VARIATION_PASSWORD = 128
TYPE_TEXT_FLAG_CAP_SENTENCES = 16384
TYPE_TEXT_FLAG_AUTO_CORRECT = 32768
@@ -1173,6 +1176,8 @@ func (w *window) SetInputHint(mode key.InputHint) {
m = TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_URI
case key.HintTelephone:
m = TYPE_CLASS_PHONE
case key.HintPassword:
m = TYPE_CLASS_TEXT | TYPE_TEXT_VARIATION_PASSWORD
default:
m = TYPE_CLASS_TEXT
}
+19 -12
View File
@@ -6,7 +6,7 @@ package app
#include <Foundation/Foundation.h>
__attribute__ ((visibility ("hidden"))) void gio_wakeupMainThread(void);
__attribute__ ((visibility ("hidden"))) CFTypeRef gio_createDisplayLink(uintptr_t handle);
__attribute__ ((visibility ("hidden"))) CFTypeRef gio_createDisplayLink(void);
__attribute__ ((visibility ("hidden"))) void gio_releaseDisplayLink(CFTypeRef dl);
__attribute__ ((visibility ("hidden"))) int gio_startDisplayLink(CFTypeRef dl);
__attribute__ ((visibility ("hidden"))) int gio_stopDisplayLink(CFTypeRef dl);
@@ -42,7 +42,7 @@ static CFTypeRef newNSString(unichar *chars, NSUInteger length) {
import "C"
import (
"errors"
"runtime/cgo"
"sync"
"sync/atomic"
"time"
"unicode/utf16"
@@ -70,6 +70,9 @@ type displayLink struct {
running uint32
}
// displayLinks maps CFTypeRefs to *displayLinks.
var displayLinks sync.Map
var mainFuncs = make(chan func(), 1)
// runOnMain runs the function on the main thread.
@@ -121,25 +124,25 @@ func stringToNSString(str string) C.CFTypeRef {
return C.newNSString(chars, C.NSUInteger(len(u16)))
}
func NewDisplayLink(callback func()) (*displayLink, error) {
func newDisplayLink(callback func()) (*displayLink, error) {
d := &displayLink{
callback: callback,
done: make(chan struct{}),
states: make(chan bool),
dids: make(chan uint64),
}
h := cgo.NewHandle(d)
dl := C.gio_createDisplayLink(C.uintptr_t(h))
dl := C.gio_createDisplayLink()
if dl == 0 {
return nil, errors.New("app: failed to create display link")
}
go d.run(dl, h)
go d.run(dl)
return d, nil
}
func (d *displayLink) run(dl C.CFTypeRef, h cgo.Handle) {
func (d *displayLink) run(dl C.CFTypeRef) {
defer C.gio_releaseDisplayLink(dl)
defer h.Delete()
displayLinks.Store(dl, d)
defer displayLinks.Delete(dl)
var stopTimer *time.Timer
var tchan <-chan time.Time
started := false
@@ -200,10 +203,14 @@ func (d *displayLink) SetDisplayID(did uint64) {
}
//export gio_onFrameCallback
func gio_onFrameCallback(dl C.CFTypeRef, handle C.uintptr_t) {
d := cgo.Handle(handle).Value().(*displayLink)
if atomic.LoadUint32(&d.running) != 0 {
d.callback()
func gio_onFrameCallback(ref C.CFTypeRef) {
d, exists := displayLinks.Load(ref)
if !exists {
return
}
dl := d.(*displayLink)
if atomic.LoadUint32(&dl.running) != 0 {
dl.callback()
}
}
+7 -7
View File
@@ -117,7 +117,7 @@ func onCreate(view, controller C.CFTypeRef) {
w := &window{
view: view,
}
dl, err := NewDisplayLink(func() {
dl, err := newDisplayLink(func() {
w.draw(false)
})
if err != nil {
@@ -236,16 +236,16 @@ func onText(view, str C.CFTypeRef) {
//export onTouch
func onTouch(last C.int, view, touchRef C.CFTypeRef, phase C.NSInteger, x, y C.CGFloat, ti C.double) {
var typ pointer.Type
var kind pointer.Kind
switch phase {
case C.UITouchPhaseBegan:
typ = pointer.Press
kind = pointer.Press
case C.UITouchPhaseMoved:
typ = pointer.Move
kind = pointer.Move
case C.UITouchPhaseEnded:
typ = pointer.Release
kind = pointer.Release
case C.UITouchPhaseCancelled:
typ = pointer.Cancel
kind = pointer.Cancel
default:
return
}
@@ -253,7 +253,7 @@ func onTouch(last C.int, view, touchRef C.CFTypeRef, phase C.NSInteger, x, y C.C
t := time.Duration(float64(ti) * float64(time.Second))
p := f32.Point{X: float32(x), Y: float32(y)}
w.w.Event(pointer.Event{
Type: typ,
Kind: kind,
Source: pointer.Touch,
PointerID: w.lookupTouch(last != 0, touchRef),
Position: p,
+5 -17
View File
@@ -123,6 +123,9 @@ static void handleTouches(int last, UIView *view, NSSet<UITouch *> *touches, UIE
@implementation GioView
NSArray<UIKeyCommand *> *_keyCommands;
+ (void)onFrameCallback:(CADisplayLink *)link {
gio_onFrameCallback((__bridge CFTypeRef)link);
}
+ (Class)layerClass {
return gio_layerClass();
}
@@ -227,23 +230,8 @@ NSArray<UIKeyCommand *> *_keyCommands;
}
@end
@interface DisplayLinkHandle : NSObject {
}
@property uintptr_t handle;
@end
@implementation DisplayLinkHandle {
}
- (void)onFrameCallback:(CADisplayLink *)link {
gio_onFrameCallback((__bridge CFTypeRef)link, _handle);
}
@end
CFTypeRef gio_createDisplayLink(uintptr_t handle) {
DisplayLinkHandle *h = [DisplayLinkHandle alloc];
h.handle = handle;
CADisplayLink *dl = [CADisplayLink displayLinkWithTarget:h selector:@selector(onFrameCallback:)];
CFTypeRef gio_createDisplayLink(void) {
CADisplayLink *dl = [CADisplayLink displayLinkWithTarget:[GioView class] selector:@selector(onFrameCallback:)];
dl.paused = YES;
NSRunLoop *runLoop = [NSRunLoop mainRunLoop];
[dl addToRunLoop:runLoop forMode:[runLoop currentMode]];
+7 -5
View File
@@ -275,7 +275,7 @@ func (w *window) addEventListeners() {
}
w.touches = w.touches[:0]
w.w.Event(pointer.Event{
Type: pointer.Cancel,
Kind: pointer.Cancel,
Source: pointer.Touch,
})
return nil
@@ -358,6 +358,8 @@ func (w *window) keyboard(hint key.InputHint) {
m = "url"
case key.HintTelephone:
m = "tel"
case key.HintPassword:
m = "password"
default:
m = "text"
}
@@ -396,7 +398,7 @@ func modifiersFor(e js.Value) key.Modifiers {
return mods
}
func (w *window) touchEvent(typ pointer.Type, e js.Value) {
func (w *window) touchEvent(kind pointer.Kind, e js.Value) {
e.Call("preventDefault")
t := time.Duration(e.Get("timeStamp").Int()) * time.Millisecond
changedTouches := e.Get("changedTouches")
@@ -424,7 +426,7 @@ func (w *window) touchEvent(typ pointer.Type, e js.Value) {
Y: float32(y) * scale,
}
w.w.Event(pointer.Event{
Type: typ,
Kind: kind,
Source: pointer.Touch,
Position: pos,
PointerID: pid,
@@ -446,7 +448,7 @@ func (w *window) touchIDFor(touch js.Value) pointer.ID {
return pid
}
func (w *window) pointerEvent(typ pointer.Type, dx, dy float32, e js.Value) {
func (w *window) pointerEvent(kind pointer.Kind, dx, dy float32, e js.Value) {
e.Call("preventDefault")
x, y := e.Get("clientX").Float(), e.Get("clientY").Float()
rect := w.cnv.Call("getBoundingClientRect")
@@ -474,7 +476,7 @@ func (w *window) pointerEvent(typ pointer.Type, dx, dy float32, e js.Value) {
btns |= pointer.ButtonTertiary
}
w.w.Event(pointer.Event{
Type: typ,
Kind: kind,
Source: pointer.Mouse,
Buttons: btns,
Position: pos,
+18 -10
View File
@@ -192,6 +192,10 @@ static CFTypeRef windowForView(CFTypeRef viewRef) {
}
static void raiseWindow(CFTypeRef windowRef) {
NSRunningApplication *currentApp = [NSRunningApplication currentApplication];
if (![currentApp isActive]) {
[currentApp activateWithOptions:(NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps)];
}
NSWindow* window = (__bridge NSWindow *)windowRef;
[window makeKeyAndOrderFront:nil];
}
@@ -297,7 +301,9 @@ func (w *window) contextView() C.CFTypeRef {
func (w *window) ReadClipboard() {
cstr := C.readClipboard()
defer C.CFRelease(cstr)
if cstr != 0 {
defer C.CFRelease(cstr)
}
content := nsstringToString(cstr)
w.w.Event(clipboard.Event{Text: content})
}
@@ -365,11 +371,11 @@ func (w *window) Configure(options []Option) {
case Minimized:
C.unhideWindow(window)
case Maximized:
if C.isWindowZoomed(window) != 0 {
C.zoomWindow(window)
}
}
w.config.Mode = Windowed
if C.isWindowZoomed(window) != 0 {
C.zoomWindow(window)
}
w.setTitle(prev, cnf)
if prev.Size != cnf.Size {
w.config.Size = cnf.Size
@@ -523,8 +529,10 @@ func gio_onMouse(view, evt C.CFTypeRef, cdir C.int, cbtn C.NSInteger, x, y, dx,
btn = pointer.ButtonPrimary
case 1:
btn = pointer.ButtonSecondary
case 2:
btn = pointer.ButtonTertiary
}
var typ pointer.Type
var typ pointer.Kind
switch cdir {
case C.MOUSE_MOVE:
typ = pointer.Move
@@ -548,7 +556,7 @@ func gio_onMouse(view, evt C.CFTypeRef, cdir C.int, cbtn C.NSInteger, x, y, dx,
panic("invalid direction")
}
w.w.Event(pointer.Event{
Type: typ,
Kind: typ,
Source: pointer.Mouse,
Time: t,
Buttons: w.pointerBtns,
@@ -788,13 +796,13 @@ func configFor(scale float32) unit.Metric {
//export gio_onClose
func gio_onClose(view C.CFTypeRef) {
w := mustView(view)
w.displayLink.Close()
w.w.Event(ViewEvent{})
deleteView(view)
w.w.Event(system.DestroyEvent{})
w.displayLink.Close()
w.displayLink = nil
deleteView(view)
C.CFRelease(w.view)
w.view = 0
w.displayLink = nil
}
//export gio_onHide
@@ -882,7 +890,7 @@ func newOSWindow() (*window, error) {
scale: scale,
redraw: make(chan struct{}, 1),
}
dl, err := NewDisplayLink(func() {
dl, err := newDisplayLink(func() {
select {
case w.redraw <- struct{}{}:
default:
+15 -9
View File
@@ -92,24 +92,30 @@ static void handleMouse(NSView *view, NSEvent *event, int typ, CGFloat dx, CGFlo
- (void)mouseUp:(NSEvent *)event {
handleMouse(self, event, MOUSE_UP, 0, 0);
}
- (void)middleMouseDown:(NSEvent *)event {
handleMouse(self, event, MOUSE_DOWN, 0, 0);
}
- (void)middleMouseUp:(NSEvent *)event {
handleMouse(self, event, MOUSE_UP, 0, 0);
}
- (void)rightMouseDown:(NSEvent *)event {
handleMouse(self, event, MOUSE_DOWN, 0, 0);
}
- (void)rightMouseUp:(NSEvent *)event {
handleMouse(self, event, MOUSE_UP, 0, 0);
}
- (void)otherMouseDown:(NSEvent *)event {
handleMouse(self, event, MOUSE_DOWN, 0, 0);
}
- (void)otherMouseUp:(NSEvent *)event {
handleMouse(self, event, MOUSE_UP, 0, 0);
}
- (void)mouseMoved:(NSEvent *)event {
handleMouse(self, event, MOUSE_MOVE, 0, 0);
}
- (void)mouseDragged:(NSEvent *)event {
handleMouse(self, event, MOUSE_MOVE, 0, 0);
}
- (void)rightMouseDragged:(NSEvent *)event {
handleMouse(self, event, MOUSE_MOVE, 0, 0);
}
- (void)otherMouseDragged:(NSEvent *)event {
handleMouse(self, event, MOUSE_MOVE, 0, 0);
}
- (void)scrollWheel:(NSEvent *)event {
CGFloat dx = -event.scrollingDeltaX;
CGFloat dy = -event.scrollingDeltaY;
@@ -193,14 +199,14 @@ static void handleMouse(NSView *view, NSEvent *event, int typ, CGFloat dx, CGFlo
static GioWindowDelegate *globalWindowDel;
static CVReturn displayLinkCallback(CVDisplayLinkRef dl, const CVTimeStamp *inNow, const CVTimeStamp *inOutputTime, CVOptionFlags flagsIn, CVOptionFlags *flagsOut, void *handle) {
gio_onFrameCallback(dl, (uintptr_t)handle);
gio_onFrameCallback(dl);
return kCVReturnSuccess;
}
CFTypeRef gio_createDisplayLink(uintptr_t handle) {
CFTypeRef gio_createDisplayLink(void) {
CVDisplayLinkRef dl;
CVDisplayLinkCreateWithActiveCGDisplays(&dl);
CVDisplayLinkSetOutputCallback(dl, displayLinkCallback, (void *)(handle));
CVDisplayLinkSetOutputCallback(dl, displayLinkCallback, nil);
return dl;
}
+11 -11
View File
@@ -791,7 +791,7 @@ func gio_onTouchDown(data unsafe.Pointer, touch *C.struct_wl_touch, serial, t C.
Y: fromFixed(y) * float32(w.scale),
}
w.w.Event(pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Source: pointer.Touch,
Position: w.lastTouch,
PointerID: pointer.ID(id),
@@ -807,7 +807,7 @@ func gio_onTouchUp(data unsafe.Pointer, touch *C.struct_wl_touch, serial, t C.ui
w := s.touchFoci[id]
delete(s.touchFoci, id)
w.w.Event(pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Source: pointer.Touch,
Position: w.lastTouch,
PointerID: pointer.ID(id),
@@ -825,7 +825,7 @@ func gio_onTouchMotion(data unsafe.Pointer, touch *C.struct_wl_touch, t C.uint32
Y: fromFixed(y) * float32(w.scale),
}
w.w.Event(pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: w.lastTouch,
Source: pointer.Touch,
PointerID: pointer.ID(id),
@@ -844,7 +844,7 @@ func gio_onTouchCancel(data unsafe.Pointer, touch *C.struct_wl_touch) {
for id, w := range s.touchFoci {
delete(s.touchFoci, id)
w.w.Event(pointer.Event{
Type: pointer.Cancel,
Kind: pointer.Cancel,
Source: pointer.Touch,
})
}
@@ -869,7 +869,7 @@ func gio_onPointerLeave(data unsafe.Pointer, p *C.struct_wl_pointer, serial C.ui
s.serial = serial
if w.inCompositor {
w.inCompositor = false
w.w.Event(pointer.Event{Type: pointer.Cancel})
w.w.Event(pointer.Event{Kind: pointer.Cancel})
}
}
@@ -917,21 +917,21 @@ func gio_onPointerButton(data unsafe.Pointer, p *C.struct_wl_pointer, serial, t,
}
}
}
var typ pointer.Type
var kind pointer.Kind
switch state {
case 0:
w.pointerBtns &^= btn
typ = pointer.Release
kind = pointer.Release
// Move or resize gestures no longer applies.
w.inCompositor = false
case 1:
w.pointerBtns |= btn
typ = pointer.Press
kind = pointer.Press
}
w.flushScroll()
w.resetFling()
w.w.Event(pointer.Event{
Type: typ,
Kind: kind,
Source: pointer.Mouse,
Buttons: w.pointerBtns,
Position: w.lastPos,
@@ -1573,7 +1573,7 @@ func (w *window) flushScroll() {
return
}
w.w.Event(pointer.Event{
Type: pointer.Scroll,
Kind: pointer.Scroll,
Source: pointer.Mouse,
Buttons: w.pointerBtns,
Position: w.lastPos,
@@ -1596,7 +1596,7 @@ func (w *window) onPointerMotion(x, y C.wl_fixed_t, t C.uint32_t) {
Y: fromFixed(y) * float32(w.scale),
}
w.w.Event(pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: w.lastPos,
Buttons: w.pointerBtns,
Source: pointer.Mouse,
+72 -58
View File
@@ -32,11 +32,6 @@ type ViewEvent struct {
HWND uintptr
}
type winDeltas struct {
width int32
height int32
}
type window struct {
hwnd syscall.Handle
hdc syscall.Handle
@@ -55,7 +50,6 @@ type window struct {
animating bool
focused bool
deltas winDeltas
borderSize image.Point
config Config
}
@@ -192,22 +186,12 @@ func createNativeWindow() (*window, error) {
// It reads the window style and size/position and updates w.config.
// If anything has changed it emits a ConfigEvent to notify the application.
func (w *window) update() {
r := windows.GetWindowRect(w.hwnd)
size := image.Point{
X: int(r.Right - r.Left - w.deltas.width),
Y: int(r.Bottom - r.Top - w.deltas.height),
cr := windows.GetClientRect(w.hwnd)
w.config.Size = image.Point{
X: int(cr.Right - cr.Left),
Y: int(cr.Bottom - cr.Top),
}
// Check the window mode.
style := windows.GetWindowLong(w.hwnd, windows.GWL_STYLE)
if style&windows.WS_OVERLAPPEDWINDOW == 0 {
size = image.Point{
X: int(r.Right - r.Left),
Y: int(r.Bottom - r.Top),
}
}
w.config.Size = size
w.borderSize = image.Pt(
windows.GetSystemMetrics(windows.SM_CXSIZEFRAME),
windows.GetSystemMetrics(windows.SM_CYSIZEFRAME),
@@ -275,7 +259,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
w.pointerButton(pointer.ButtonTertiary, false, lParam, getModifiers())
case windows.WM_CANCELMODE:
w.w.Event(pointer.Event{
Type: pointer.Cancel,
Kind: pointer.Cancel,
})
case windows.WM_SETFOCUS:
w.focused = true
@@ -304,7 +288,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
x, y := coordsFromlParam(lParam)
p := f32.Point{X: float32(x), Y: float32(y)}
w.w.Event(pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Source: pointer.Mouse,
Position: p,
Buttons: w.pointerBtns,
@@ -325,6 +309,28 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
// The system destroys the HWND for us.
w.hwnd = 0
windows.PostQuitMessage(0)
case windows.WM_NCCALCSIZE:
if w.config.Decorated {
// Let Windows handle decorations.
break
}
// No client areas; we draw decorations ourselves.
if wParam != 1 {
return 0
}
// lParam contains an NCCALCSIZE_PARAMS for us to adjust.
place := windows.GetWindowPlacement(w.hwnd)
if !place.IsMaximized() {
// Nothing do adjust.
return 0
}
// Adjust window position to avoid the extra padding in maximized
// state. See https://devblogs.microsoft.com/oldnewthing/20150304-00/?p=44543.
// Note that trying to do the adjustment in WM_GETMINMAXINFO is ignored by Windows.
szp := (*windows.NCCalcSizeParams)(unsafe.Pointer(uintptr(lParam)))
mi := windows.GetMonitorInfo(w.hwnd)
szp.Rgrc[0] = mi.WorkArea
return 0
case windows.WM_PAINT:
w.draw(true)
case windows.WM_SIZE:
@@ -344,18 +350,26 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr
}
case windows.WM_GETMINMAXINFO:
mm := (*windows.MinMaxInfo)(unsafe.Pointer(uintptr(lParam)))
var bw, bh int32
if w.config.Decorated {
r := windows.GetWindowRect(w.hwnd)
cr := windows.GetClientRect(w.hwnd)
bw = r.Right - r.Left - (cr.Right - cr.Left)
bh = r.Bottom - r.Top - (cr.Bottom - cr.Top)
}
if p := w.config.MinSize; p.X > 0 || p.Y > 0 {
mm.PtMinTrackSize = windows.Point{
X: int32(p.X) + w.deltas.width,
Y: int32(p.Y) + w.deltas.height,
X: int32(p.X) + bw,
Y: int32(p.Y) + bh,
}
}
if p := w.config.MaxSize; p.X > 0 || p.Y > 0 {
mm.PtMaxTrackSize = windows.Point{
X: int32(p.X) + w.deltas.width,
Y: int32(p.Y) + w.deltas.height,
X: int32(p.X) + bw,
Y: int32(p.Y) + bh,
}
}
return 0
case windows.WM_SETCURSOR:
w.cursorIn = (lParam & 0xffff) == windows.HTCLIENT
if w.cursorIn {
@@ -446,23 +460,18 @@ func (w *window) hitTest(x, y int) uintptr {
if w.config.Mode == Fullscreen {
return windows.HTCLIENT
}
p := f32.Pt(float32(x), float32(y))
if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove {
return windows.HTCAPTION
}
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 {
default:
fallthrough
case !top && !bottom && !left && !right:
return windows.HTCLIENT
case top && left:
return windows.HTTOPLEFT
case top && right:
@@ -480,6 +489,11 @@ func (w *window) hitTest(x, y int) uintptr {
case right:
return windows.HTRIGHT
}
p := f32.Pt(float32(x), float32(y))
if a, ok := w.w.ActionAt(p); ok && a == system.ActionMove {
return windows.HTCAPTION
}
return windows.HTCLIENT
}
func (w *window) pointerButton(btn pointer.Buttons, press bool, lParam uintptr, kmods key.Modifiers) {
@@ -487,15 +501,15 @@ func (w *window) pointerButton(btn pointer.Buttons, press bool, lParam uintptr,
windows.SetFocus(w.hwnd)
}
var typ pointer.Type
var kind pointer.Kind
if press {
typ = pointer.Press
kind = pointer.Press
if w.pointerBtns == 0 {
windows.SetCapture(w.hwnd)
}
w.pointerBtns |= btn
} else {
typ = pointer.Release
kind = pointer.Release
w.pointerBtns &^= btn
if w.pointerBtns == 0 {
windows.ReleaseCapture()
@@ -504,7 +518,7 @@ func (w *window) pointerButton(btn pointer.Buttons, press bool, lParam uintptr,
x, y := coordsFromlParam(lParam)
p := f32.Point{X: float32(x), Y: float32(y)}
w.w.Event(pointer.Event{
Type: typ,
Kind: kind,
Source: pointer.Mouse,
Position: p,
Buttons: w.pointerBtns,
@@ -539,7 +553,7 @@ func (w *window) scrollEvent(wParam, lParam uintptr, horizontal bool, kmods key.
}
}
w.w.Event(pointer.Event{
Type: pointer.Scroll,
Kind: pointer.Scroll,
Source: pointer.Mouse,
Position: p,
Buttons: w.pointerBtns,
@@ -669,9 +683,6 @@ func (w *window) Configure(options []Option) {
swpStyle := uintptr(windows.SWP_NOZORDER | windows.SWP_FRAMECHANGED)
winStyle := uintptr(windows.WS_OVERLAPPEDWINDOW)
style &^= winStyle
if !w.config.Decorated {
winStyle = 0
}
switch w.config.Mode {
case Minimized:
style |= winStyle
@@ -684,41 +695,44 @@ func (w *window) Configure(options []Option) {
showMode = windows.SW_SHOWMAXIMIZED
case Windowed:
windows.SetWindowText(w.hwnd, w.config.Title)
style |= winStyle
showMode = windows.SW_SHOWNORMAL
// Get target for client areaa size.
// Get target for client area size.
width = int32(w.config.Size.X)
height = int32(w.config.Size.Y)
// Get the current window size and position.
wr := windows.GetWindowRect(w.hwnd)
// Set desired window size.
wr.Right = wr.Left + width
wr.Bottom = wr.Top + height
// Convert from client size to window size.
r := wr
windows.AdjustWindowRectEx(&r, uint32(style), 0, dwExStyle)
// Calculate difference between client and full window sizes.
w.deltas.width = r.Right - wr.Right + wr.Left - r.Left
w.deltas.height = r.Bottom - wr.Bottom + wr.Top - r.Top
// Set new window size and position.
x = wr.Left
y = wr.Top
width = r.Right - r.Left
height = r.Bottom - r.Top
if w.config.Decorated {
// Compute client size and position. Note that the client size is
// equal to the window size when we are in control of decorations.
r := windows.Rect{
Right: width,
Bottom: height,
}
windows.AdjustWindowRectEx(&r, uint32(style), 0, dwExStyle)
width = r.Right - r.Left
height = r.Bottom - r.Top
}
if !w.config.Decorated {
// Enable drop shadows when we draw decorations.
windows.DwmExtendFrameIntoClientArea(w.hwnd, windows.Margins{-1, -1, -1, -1})
}
case Fullscreen:
swpStyle |= windows.SWP_NOMOVE | windows.SWP_NOSIZE
mi := windows.GetMonitorInfo(w.hwnd)
x, y = mi.Monitor.Left, mi.Monitor.Top
width = mi.Monitor.Right - mi.Monitor.Left
height = mi.Monitor.Bottom - mi.Monitor.Top
showMode = windows.SW_SHOW
showMode = windows.SW_SHOWMAXIMIZED
}
windows.SetWindowLong(w.hwnd, windows.GWL_STYLE, style)
windows.SetWindowPos(w.hwnd, 0, x, y, width, height, swpStyle)
windows.ShowWindow(w.hwnd, showMode)
w.w.Event(ConfigEvent{Config: w.config})
w.update()
}
func (w *window) WriteClipboard(s string) {
+7 -7
View File
@@ -547,7 +547,7 @@ func (h *x11EventHandler) handleEvents() bool {
case C.ButtonPress, C.ButtonRelease:
bevt := (*C.XButtonEvent)(unsafe.Pointer(xev))
ev := pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Source: pointer.Mouse,
Position: f32.Point{
X: float32(bevt.x),
@@ -557,7 +557,7 @@ func (h *x11EventHandler) handleEvents() bool {
Modifiers: w.xkb.Modifiers(),
}
if bevt._type == C.ButtonRelease {
ev.Type = pointer.Release
ev.Kind = pointer.Release
}
var btn pointer.Buttons
const scrollScale = 10
@@ -569,7 +569,7 @@ func (h *x11EventHandler) handleEvents() bool {
case C.Button3:
btn = pointer.ButtonSecondary
case C.Button4:
ev.Type = pointer.Scroll
ev.Kind = pointer.Scroll
// scroll up or left (if shift is pressed).
if ev.Modifiers == key.ModShift {
ev.Scroll.X = -scrollScale
@@ -578,7 +578,7 @@ func (h *x11EventHandler) handleEvents() bool {
}
case C.Button5:
// scroll down or right (if shift is pressed).
ev.Type = pointer.Scroll
ev.Kind = pointer.Scroll
if ev.Modifiers == key.ModShift {
ev.Scroll.X = +scrollScale
} else {
@@ -587,11 +587,11 @@ func (h *x11EventHandler) handleEvents() bool {
case 6:
// http://xahlee.info/linux/linux_x11_mouse_button_number.html
// scroll left.
ev.Type = pointer.Scroll
ev.Kind = pointer.Scroll
ev.Scroll.X = -scrollScale * 2
case 7:
// scroll right
ev.Type = pointer.Scroll
ev.Kind = pointer.Scroll
ev.Scroll.X = +scrollScale * 2
default:
continue
@@ -607,7 +607,7 @@ func (h *x11EventHandler) handleEvents() bool {
case C.MotionNotify:
mevt := (*C.XMotionEvent)(unsafe.Pointer(xev))
w.w.Event(pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Source: pointer.Mouse,
Buttons: w.pointerBtns,
Position: f32.Point{
+61 -32
View File
@@ -16,6 +16,7 @@ import (
"gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/gpu"
"gioui.org/internal/debug"
"gioui.org/internal/ops"
"gioui.org/io/event"
"gioui.org/io/key"
@@ -25,6 +26,7 @@ import (
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
@@ -59,6 +61,8 @@ type Window struct {
// actions are the actions waiting to be performed.
actions chan system.Action
// out is where the platform backend delivers events bound for the
// user program.
out chan event.Event
frames chan *op.Ops
frameAck chan struct{}
@@ -105,6 +109,16 @@ type Window struct {
}
imeState editorState
// event stores the state required for processing and delivering events
// from NextEvent. If we had support for range over func, this would
// be the iterator state.
eventState struct {
created bool
initialOpts []Option
wakeup func()
timer *time.Timer
}
}
type editorState struct {
@@ -136,9 +150,11 @@ type queue struct {
// Calling NewWindow more than once is not supported on
// iOS, Android, WebAssembly.
func NewWindow(options ...Option) *Window {
debug.Parse()
// Measure decoration height.
deco := new(widget.Decorations)
theme := material.NewTheme(gofont.Regular())
theme := material.NewTheme()
theme.Shaper = text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Regular()))
decoStyle := material.Decorations(theme, deco, 0, "")
gtx := layout.Context{
Ops: new(op.Ops),
@@ -181,7 +197,7 @@ func NewWindow(options ...Option) *Window {
w.imeState.compose = key.Range{Start: -1, End: -1}
w.semantic.ids = make(map[router.SemanticID]router.SemanticNode)
w.callbacks.w = w
go w.run(options)
w.eventState.initialOpts = options
return w
}
@@ -191,11 +207,6 @@ func decoHeightOpt(h unit.Dp) Option {
}
}
// Events returns the channel where events are delivered.
func (w *Window) Events() <-chan event.Event {
return w.out
}
// update the window contents, input operations declare input handlers,
// and so on. The supplied operations list completely replaces the window state
// from previous calls.
@@ -709,7 +720,7 @@ func (w *Window) waitAck(d driver) {
select {
case f := <-w.driverFuncs:
f(d)
case w.out <- event.Event(nil):
case w.out <- theFlushEvent:
// A dummy event went through, so we know the application has processed the previous event.
return
case <-w.immediateRedraws:
@@ -743,7 +754,7 @@ func (w *Window) waitFrame(d driver) *op.Ops {
case frame := <-w.frames:
// The client called FrameEvent.Frame.
return frame
case w.out <- event.Event(nil):
case w.out <- theFlushEvent:
// The client ignored FrameEvent and continued processing
// events.
return nil
@@ -877,7 +888,6 @@ func (w *Window) processEvent(d driver, e event.Event) bool {
if err := w.validateAndProcess(d, viewSize, e2.Sync, wrapper, signal); err != nil {
w.destroyGPU()
w.out <- system.DestroyEvent{Err: err}
close(w.out)
close(w.destroy)
break
}
@@ -886,7 +896,6 @@ func (w *Window) processEvent(d driver, e event.Event) bool {
case system.DestroyEvent:
w.destroyGPU()
w.out <- e2
close(w.out)
close(w.destroy)
case ViewEvent:
w.out <- e2
@@ -895,6 +904,7 @@ func (w *Window) processEvent(d driver, e event.Event) bool {
w.decorations.Config = e2.Config
e2.Config = w.effectiveConfig()
w.out <- e2
case wakeupEvent:
case event.Event:
handled := w.queue.q.Queue(e2)
if e, ok := e.(key.Event); ok && !handled {
@@ -934,43 +944,51 @@ func (w *Window) processEvent(d driver, e event.Event) bool {
return true
}
func (w *Window) run(options []Option) {
if err := newWindow(&w.callbacks, options); err != nil {
w.out <- system.DestroyEvent{Err: err}
close(w.out)
close(w.destroy)
return
// NextEvent blocks until an event is received from the window, such as
// [io/system.FrameEvent]. It blocks forever if called after [io/system.DestroyEvent]
// has been returned.
func (w *Window) NextEvent() event.Event {
state := &w.eventState
if !state.created {
state.created = true
if err := newWindow(&w.callbacks, state.initialOpts); err != nil {
close(w.destroy)
return system.DestroyEvent{Err: err}
}
}
var wakeup func()
var timer *time.Timer
for {
var (
wakeups <-chan struct{}
timeC <-chan time.Time
)
if wakeup != nil {
if state.wakeup != nil {
wakeups = w.wakeups
if timer != nil {
timeC = timer.C
if state.timer != nil {
timeC = state.timer.C
}
}
select {
case t := <-w.scheduledRedraws:
if timer != nil {
timer.Stop()
if state.timer != nil {
state.timer.Stop()
}
timer = time.NewTimer(time.Until(t))
case <-w.destroy:
return
state.timer = time.NewTimer(time.Until(t))
case e := <-w.out:
// Receiving a flushEvent indicates to the platform backend that
// all previous events have been processed by the user program.
if _, ok := e.(flushEvent); ok {
break
}
return e
case <-timeC:
select {
case w.redraws <- struct{}{}:
wakeup()
state.wakeup()
default:
}
case <-wakeups:
wakeup()
case wakeup = <-w.wakeupFuncs:
state.wakeup()
case state.wakeup = <-w.wakeupFuncs:
}
}
}
@@ -1018,9 +1036,9 @@ func (w *Window) decorate(d driver, e system.FrameEvent, o *op.Ops) (size, offse
Metric: e.Metric,
Constraints: layout.Exact(e.Size),
}
style.Layout(gtx)
// Update the window based on the actions on the decorations.
w.Perform(deco.Actions())
w.Perform(deco.Update(gtx))
style.Layout(gtx)
// Offset to place the frame content below the decorations.
decoHeight := gtx.Dp(w.decorations.Config.decoHeight)
if w.decorations.currentHeight != decoHeight {
@@ -1161,3 +1179,14 @@ func Decorated(enabled bool) Option {
cnf.Decorated = 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.
type flushEvent struct{}
func (t flushEvent) ImplementsEvent() {}
// theFlushEvent avoids allocating garbage when sending
// flushEvents.
var theFlushEvent flushEvent
+41 -9
View File
@@ -3,7 +3,9 @@ Package font provides type describing font faces attributes.
*/
package font
import "github.com/go-text/typesetting/font"
import (
"github.com/go-text/typesetting/font"
)
// A FontFace is a Font and a matching Face.
type FontFace struct {
@@ -20,10 +22,12 @@ type Weight int
// Font specify a particular typeface variant, style and weight.
type Font struct {
// Typeface specifies the name(s) of the the font faces to try. See [Typeface]
// for details.
Typeface Typeface
Variant Variant
Style Style
// Weight is the text weight. If zero, Normal is used instead.
// Style specifies the kind of text style.
Style Style
// Weight is the text weight.
Weight Weight
}
@@ -33,13 +37,41 @@ type Face interface {
Face() font.Face
}
// Typeface identifies a particular typeface design. The empty
// string denotes the default typeface.
// Typeface identifies a list of font families to attempt to use for displaying
// a string. The syntax is a comma-delimited list of family names. In order to
// allow for the remote possibility of needing to express a font family name
// containing a comma, name entries may be quoted using either single or double
// quotes. Within quotes, a literal quotation mark can be expressed by escaping
// it with `\`. A literal backslash may be expressed by escaping it with another
// `\`.
//
// Here's an example Typeface:
//
// Times New Roman, Georgia, serif
//
// This is equivalent to the above:
//
// "Times New Roman", 'Georgia', serif
//
// Here are some valid uses of escape sequences:
//
// "Contains a literal \" doublequote", 'Literal \' Singlequote', "\\ Literal backslash", '\\ another'
//
// This syntax has the happy side effect that most CSS "font-family" rules are
// valid Typefaces (without the trailing semicolon).
//
// Generic CSS font families are supported, and are automatically expanded to lists
// of known font families with a matching style. The supported generic families are:
//
// - fantasy
// - math
// - emoji
// - serif
// - sans-serif
// - cursive
// - monospace
type Typeface string
// Variant denotes a typeface variant such as "Mono" or "Smallcaps".
type Variant string
const (
Regular Style = iota
Italic
+16 -17
View File
@@ -37,11 +37,11 @@ var (
func loadRegular() {
regOnce.Do(func() {
face, err := opentype.Parse(goregular.TTF)
faces, err := opentype.ParseCollection(goregular.TTF)
if err != nil {
panic(fmt.Errorf("failed to parse font: %v", err))
}
reg = []font.FontFace{{Font: font.Font{Typeface: "Go"}, Face: face}}
reg = faces
collection = append(collection, reg[0])
})
}
@@ -56,17 +56,17 @@ func Regular() []font.FontFace {
func Collection() []font.FontFace {
loadRegular()
once.Do(func() {
register(font.Font{Style: font.Italic}, goitalic.TTF)
register(font.Font{Weight: font.Bold}, gobold.TTF)
register(font.Font{Style: font.Italic, Weight: font.Bold}, gobolditalic.TTF)
register(font.Font{Weight: font.Medium}, gomedium.TTF)
register(font.Font{Weight: font.Medium, Style: font.Italic}, gomediumitalic.TTF)
register(font.Font{Variant: "Mono"}, gomono.TTF)
register(font.Font{Variant: "Mono", Weight: font.Bold}, gomonobold.TTF)
register(font.Font{Variant: "Mono", Weight: font.Bold, Style: font.Italic}, gomonobolditalic.TTF)
register(font.Font{Variant: "Mono", Style: font.Italic}, gomonoitalic.TTF)
register(font.Font{Variant: "Smallcaps"}, gosmallcaps.TTF)
register(font.Font{Variant: "Smallcaps", Style: font.Italic}, gosmallcapsitalic.TTF)
register(goitalic.TTF)
register(gobold.TTF)
register(gobolditalic.TTF)
register(gomedium.TTF)
register(gomediumitalic.TTF)
register(gomono.TTF)
register(gomonobold.TTF)
register(gomonobolditalic.TTF)
register(gomonoitalic.TTF)
register(gosmallcaps.TTF)
register(gosmallcapsitalic.TTF)
// Ensure that any outside appends will not reuse the backing store.
n := len(collection)
collection = collection[:n:n]
@@ -74,11 +74,10 @@ func Collection() []font.FontFace {
return collection
}
func register(fnt font.Font, ttf []byte) {
face, err := opentype.Parse(ttf)
func register(ttf []byte) {
faces, err := opentype.ParseCollection(ttf)
if err != nil {
panic(fmt.Errorf("failed to parse font: %v", err))
}
fnt.Typeface = "Go"
collection = append(collection, font.FontFace{Font: fnt, Face: face})
collection = append(collection, faces[0])
}
+71 -31
View File
@@ -26,10 +26,8 @@ import (
// should construct a face for any given font file once, reusing it across different
// text shapers.
type Face struct {
face font.Font
aspect metadata.Aspect
family string
variant string
face font.Font
font giofont.Font
}
// Parse constructs a Face from source bytes.
@@ -38,15 +36,13 @@ func Parse(src []byte) (Face, error) {
if err != nil {
return Face{}, err
}
font, aspect, family, variant, err := parseLoader(ld)
font, md, err := parseLoader(ld)
if err != nil {
return Face{}, fmt.Errorf("failed parsing truetype font: %w", err)
}
return Face{
face: font,
aspect: aspect,
family: family,
variant: variant,
face: font,
font: md,
}, nil
}
@@ -63,15 +59,13 @@ func ParseCollection(src []byte) ([]giofont.FontFace, error) {
}
out := make([]giofont.FontFace, len(lds))
for i, ld := range lds {
face, aspect, family, variant, err := parseLoader(ld)
face, md, err := parseLoader(ld)
if err != nil {
return nil, fmt.Errorf("reading font %d of collection: %s", i, err)
}
ff := Face{
face: face,
aspect: aspect,
family: family,
variant: variant,
face: face,
font: md,
}
out[i] = giofont.FontFace{
Face: ff,
@@ -82,17 +76,32 @@ func ParseCollection(src []byte) ([]giofont.FontFace, error) {
return out, nil
}
func DescriptionToFont(md metadata.Description) giofont.Font {
return giofont.Font{
Typeface: giofont.Typeface(md.Family),
Style: gioStyle(md.Aspect.Style),
Weight: gioWeight(md.Aspect.Weight),
}
}
func FontToDescription(font giofont.Font) metadata.Description {
return metadata.Description{
Family: string(font.Typeface),
Aspect: metadata.Aspect{
Style: mdStyle(font.Style),
Weight: mdWeight(font.Weight),
},
}
}
// parseLoader parses the contents of the loader into a face and its metadata.
func parseLoader(ld *loader.Loader) (_ font.Font, _ metadata.Aspect, family, variant string, _ error) {
func parseLoader(ld *loader.Loader) (font.Font, giofont.Font, error) {
ft, err := fontapi.NewFont(ld)
if err != nil {
return nil, metadata.Aspect{}, "", "", err
return nil, giofont.Font{}, err
}
data := metadata.Metadata(ld)
if data.IsMonospace {
variant = "Mono"
}
return ft, data.Aspect, data.Family, variant, nil
data := DescriptionToFont(metadata.Metadata(ld))
return ft, data, nil
}
// Face returns a thread-unsafe wrapper for this Face suitable for use by a single shaper.
@@ -107,16 +116,11 @@ func (f Face) Face() font.Face {
// BUG(whereswaldon): the only Variant that can be detected automatically is
// "Mono".
func (f Face) Font() giofont.Font {
return giofont.Font{
Typeface: giofont.Typeface(f.family),
Style: f.style(),
Weight: f.weight(),
Variant: giofont.Variant(f.variant),
}
return f.font
}
func (f Face) style() giofont.Style {
switch f.aspect.Style {
func gioStyle(s metadata.Style) giofont.Style {
switch s {
case metadata.StyleItalic:
return giofont.Italic
case metadata.StyleNormal:
@@ -126,8 +130,19 @@ func (f Face) style() giofont.Style {
}
}
func (f Face) weight() giofont.Weight {
switch f.aspect.Weight {
func mdStyle(g giofont.Style) metadata.Style {
switch g {
case giofont.Italic:
return metadata.StyleItalic
case giofont.Regular:
fallthrough
default:
return metadata.StyleNormal
}
}
func gioWeight(w metadata.Weight) giofont.Weight {
switch w {
case metadata.WeightThin:
return giofont.Thin
case metadata.WeightExtraLight:
@@ -150,3 +165,28 @@ func (f Face) weight() giofont.Weight {
return giofont.Normal
}
}
func mdWeight(g giofont.Weight) metadata.Weight {
switch g {
case giofont.Thin:
return metadata.WeightThin
case giofont.ExtraLight:
return metadata.WeightExtraLight
case giofont.Light:
return metadata.WeightLight
case giofont.Normal:
return metadata.WeightNormal
case giofont.Medium:
return metadata.WeightMedium
case giofont.SemiBold:
return metadata.WeightSemibold
case giofont.Bold:
return metadata.WeightBold
case giofont.ExtraBold:
return metadata.WeightExtraBold
case giofont.Black:
return metadata.WeightBlack
default:
return metadata.WeightNormal
}
}
+38 -39
View File
@@ -39,18 +39,18 @@ type Hover struct {
func (h *Hover) Add(ops *op.Ops) {
pointer.InputOp{
Tag: h,
Types: pointer.Enter | pointer.Leave,
Kinds: pointer.Enter | pointer.Leave,
}.Add(ops)
}
// Hovered returns whether a pointer is inside the area.
func (h *Hover) Hovered(q event.Queue) bool {
// Update state and report whether a pointer is inside the area.
func (h *Hover) Update(q event.Queue) bool {
for _, ev := range q.Events(h) {
e, ok := ev.(pointer.Event)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Leave, pointer.Cancel:
if h.entered && h.pid == e.PointerID {
h.entered = false
@@ -87,10 +87,10 @@ type Click struct {
}
// ClickEvent represent a click action, either a
// TypePress for the beginning of a click or a
// TypeClick for a completed click.
// KindPress for the beginning of a click or a
// KindClick for a completed click.
type ClickEvent struct {
Type ClickType
Kind ClickKind
Position image.Point
Source pointer.Source
Modifiers key.Modifiers
@@ -99,7 +99,7 @@ type ClickEvent struct {
NumClicks int
}
type ClickType uint8
type ClickKind uint8
// Drag detects drag gestures in the form of pointer.Drag events.
type Drag struct {
@@ -136,15 +136,15 @@ const (
)
const (
// TypePress is reported for the first pointer
// KindPress is reported for the first pointer
// press.
TypePress ClickType = iota
// TypeClick is reported when a click action
KindPress ClickKind = iota
// KindClick is reported when a click action
// is complete.
TypeClick
// TypeCancel is reported when the gesture is
KindClick
// KindCancel is reported when the gesture is
// cancelled.
TypeCancel
KindCancel
)
const (
@@ -163,7 +163,7 @@ const touchSlop = unit.Dp(3)
func (c *Click) Add(ops *op.Ops) {
pointer.InputOp{
Tag: c,
Types: pointer.Press | pointer.Release | pointer.Enter | pointer.Leave,
Kinds: pointer.Press | pointer.Release | pointer.Enter | pointer.Leave,
}.Add(ops)
}
@@ -177,24 +177,24 @@ func (c *Click) Pressed() bool {
return c.pressed
}
// Events returns the next click events, if any.
func (c *Click) Events(q event.Queue) []ClickEvent {
// Update state and return the click events.
func (c *Click) Update(q event.Queue) []ClickEvent {
var events []ClickEvent
for _, evt := range q.Events(c) {
e, ok := evt.(pointer.Event)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Release:
if !c.pressed || c.pid != e.PointerID {
break
}
c.pressed = false
if !c.entered || c.hovered {
events = append(events, ClickEvent{Type: TypeClick, Position: e.Position.Round(), Source: e.Source, Modifiers: e.Modifiers, NumClicks: c.clicks})
events = append(events, ClickEvent{Kind: KindClick, Position: e.Position.Round(), Source: e.Source, Modifiers: e.Modifiers, NumClicks: c.clicks})
} else {
events = append(events, ClickEvent{Type: TypeCancel})
events = append(events, ClickEvent{Kind: KindCancel})
}
case pointer.Cancel:
wasPressed := c.pressed
@@ -202,7 +202,7 @@ func (c *Click) Events(q event.Queue) []ClickEvent {
c.hovered = false
c.entered = false
if wasPressed {
events = append(events, ClickEvent{Type: TypeCancel})
events = append(events, ClickEvent{Kind: KindCancel})
}
case pointer.Press:
if c.pressed {
@@ -224,7 +224,7 @@ func (c *Click) Events(q event.Queue) []ClickEvent {
c.clicks = 1
}
c.clickedAt = e.Time
events = append(events, ClickEvent{Type: TypePress, Position: e.Position.Round(), Source: e.Source, Modifiers: e.Modifiers, NumClicks: c.clicks})
events = append(events, ClickEvent{Kind: KindPress, Position: e.Position.Round(), Source: e.Source, Modifiers: e.Modifiers, NumClicks: c.clicks})
case pointer.Leave:
if !c.pressed {
c.pid = e.PointerID
@@ -254,7 +254,7 @@ func (s *Scroll) Add(ops *op.Ops, bounds image.Rectangle) {
oph := pointer.InputOp{
Tag: s,
Grab: s.grab,
Types: pointer.Press | pointer.Drag | pointer.Release | pointer.Scroll,
Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Scroll,
ScrollBounds: bounds,
}
oph.Add(ops)
@@ -268,9 +268,8 @@ func (s *Scroll) Stop() {
s.flinger = fling.Animation{}
}
// Scroll detects the scrolling distance from the available events and
// ongoing fling gestures.
func (s *Scroll) Scroll(cfg unit.Metric, q event.Queue, t time.Time, axis Axis) int {
// Update state and report the scroll distance along axis.
func (s *Scroll) Update(cfg unit.Metric, q event.Queue, t time.Time, axis Axis) int {
if s.axis != axis {
s.axis = axis
return 0
@@ -281,7 +280,7 @@ func (s *Scroll) Scroll(cfg unit.Metric, q event.Queue, t time.Time, axis Axis)
if !ok {
continue
}
switch e.Type {
switch e.Kind {
case pointer.Press:
if s.dragging {
break
@@ -368,12 +367,12 @@ func (d *Drag) Add(ops *op.Ops) {
pointer.InputOp{
Tag: d,
Grab: d.grab,
Types: pointer.Press | pointer.Drag | pointer.Release,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
}.Add(ops)
}
// Events returns the next drag events, if any.
func (d *Drag) Events(cfg unit.Metric, q event.Queue, axis Axis) []pointer.Event {
// Update state and return the drag events.
func (d *Drag) Update(cfg unit.Metric, q event.Queue, axis Axis) []pointer.Event {
var events []pointer.Event
for _, e := range q.Events(d) {
e, ok := e.(pointer.Event)
@@ -381,7 +380,7 @@ func (d *Drag) Events(cfg unit.Metric, q event.Queue, axis Axis) []pointer.Event
continue
}
switch e.Type {
switch e.Kind {
case pointer.Press:
if !(e.Buttons == pointer.ButtonPrimary || e.Source == pointer.Touch) {
continue
@@ -444,16 +443,16 @@ func (a Axis) String() string {
}
}
func (ct ClickType) String() string {
func (ct ClickKind) String() string {
switch ct {
case TypePress:
return "TypePress"
case TypeClick:
return "TypeClick"
case TypeCancel:
return "TypeCancel"
case KindPress:
return "KindPress"
case KindClick:
return "KindClick"
case KindCancel:
return "KindCancel"
default:
panic("invalid ClickType")
panic("invalid ClickKind")
}
}
+8 -8
View File
@@ -26,16 +26,16 @@ func TestHover(t *testing.T) {
r.Frame(ops)
r.Queue(
pointer.Event{Type: pointer.Move, Position: f32.Pt(30, 30)},
pointer.Event{Kind: pointer.Move, Position: f32.Pt(30, 30)},
)
if !h.Hovered(r) {
if !h.Update(r) {
t.Fatal("expected hovered")
}
r.Queue(
pointer.Event{Type: pointer.Move, Position: f32.Pt(50, 50)},
pointer.Event{Kind: pointer.Move, Position: f32.Pt(50, 50)},
)
if h.Hovered(r) {
if h.Update(r) {
t.Fatal("expected not hovered")
}
}
@@ -75,7 +75,7 @@ func TestMouseClicks(t *testing.T) {
r.Frame(&ops)
r.Queue(tc.events...)
events := click.Events(&r)
events := click.Update(&r)
clicks := filterMouseClicks(events)
if got, want := len(clicks), len(tc.clicks); got != want {
t.Fatalf("got %d mouse clicks, expected %d", got, want)
@@ -92,7 +92,7 @@ func TestMouseClicks(t *testing.T) {
func mouseClickEvents(times ...time.Duration) []event.Event {
press := pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
}
@@ -101,7 +101,7 @@ func mouseClickEvents(times ...time.Duration) []event.Event {
press := press
press.Time = t
release := press
release.Type = pointer.Release
release.Kind = pointer.Release
events = append(events, press, release)
}
return events
@@ -110,7 +110,7 @@ func mouseClickEvents(times ...time.Duration) []event.Event {
func filterMouseClicks(events []ClickEvent) []ClickEvent {
var clicks []ClickEvent
for _, ev := range events {
if ev.Type == TypeClick {
if ev.Kind == KindClick {
clicks = append(clicks, ev)
}
}
+3 -3
View File
@@ -1,12 +1,12 @@
module gioui.org
go 1.18
go 1.19
require (
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2
gioui.org/shader v1.0.6
github.com/go-text/typesetting v0.0.0-20230602202114-9797aefac433
gioui.org/shader v1.0.8
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95
golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91
golang.org/x/image v0.5.0
+5 -5
View File
@@ -3,11 +3,11 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8v
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc=
gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.6 h1:cvZmU+eODFR2545X+/8XucgZdTtEjR3QWW6W65b0q5Y=
gioui.org/shader v1.0.6/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/go-text/typesetting v0.0.0-20230602202114-9797aefac433 h1:Pdyvqsfi1QYgFfZa4R8otBOtgO+CGyBDMEG8cM3jwvE=
github.com/go-text/typesetting v0.0.0-20230602202114-9797aefac433/go.mod h1:KmrpWuSMFcO2yjmyhGpnBGQHSKAoEgMTSSzvLDzCuEA=
github.com/go-text/typesetting-utils v0.0.0-20230412163830-89e4bcfa3ecc h1:9Kf84pnrmmjdRzZIkomfjowmGUhHs20jkrWYw/I6CYc=
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.0.0-20230803102845-24e03d8b5372 h1:FQivqchis6bE2/9uF70M2gmmLpe82esEm2QadL0TEJo=
github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372/go.mod h1:evDBbvNR/KaVFZ2ZlDSOWWXIUKq0wCOEtzLxRM8SG3k=
github.com/go-text/typesetting-utils v0.0.0-20230616150549-2a7df14b6a22 h1:LBQTFxP2MfsyEDqSKmUBZaDuDHN1vpqDyOZjcqS7MYI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+226 -46
View File
@@ -68,22 +68,40 @@ type renderer struct {
pather *pather
packer packer
intersections packer
layers packer
layerFBOs fboSet
}
type drawOps struct {
profile bool
reader ops.Reader
states []f32.Affine2D
transStack []f32.Affine2D
vertCache []byte
viewport image.Point
clear bool
clearColor f32color.RGBA
imageOps []imageOp
pathOps []*pathOp
pathOpCache []pathOp
qs quadSplitter
pathCache *opCache
profile bool
reader ops.Reader
states []f32.Affine2D
transStack []f32.Affine2D
layers []opacityLayer
opacityStack []int
vertCache []byte
viewport image.Point
clear bool
clearColor f32color.RGBA
imageOps []imageOp
pathOps []*pathOp
pathOpCache []pathOp
qs quadSplitter
pathCache *opCache
}
type opacityLayer struct {
opacity float32
parent int
// depth of the opacity stack. Layers of equal depth are
// independent and may be packed into one atlas.
depth int
// opStart and opEnd denote the range of drawOps.imageOps
// that belong to the layer.
opStart, opEnd int
// clip of the layer operations.
clip image.Rectangle
place placement
}
type drawState struct {
@@ -127,7 +145,12 @@ type imageOp struct {
clip image.Rectangle
material material
clipType clipType
place placement
// place is either a placement in the path fbos or intersection fbos,
// depending on clipType.
place placement
// layerOps is the number of operations this
// operation replaces.
layerOps int
}
func decodeStrokeOp(data []byte) float32 {
@@ -154,17 +177,25 @@ type material struct {
// For materialTypeColor.
color f32color.RGBA
// For materialTypeLinearGradient.
color1 f32color.RGBA
color2 f32color.RGBA
color1 f32color.RGBA
color2 f32color.RGBA
opacity float32
// For materialTypeTexture.
data imageOpData
tex driver.Texture
uvTrans f32.Affine2D
}
const (
filterLinear = 0
filterNearest = 1
)
// imageOpData is the shadow of paint.ImageOp.
type imageOpData struct {
src *image.RGBA
handle interface{}
filter byte
}
type linearGradientOpData struct {
@@ -182,6 +213,7 @@ func decodeImageOp(data []byte, refs []interface{}) imageOpData {
return imageOpData{
src: refs[0].(*image.RGBA),
handle: handle,
filter: data[1],
}
}
@@ -222,8 +254,6 @@ func decodeLinearGradientOp(data []byte) linearGradientOpData {
}
}
type clipType uint8
type resource interface {
release()
}
@@ -273,6 +303,9 @@ type blitUniforms struct {
transform [4]float32
uvTransformR1 [4]float32
uvTransformR2 [4]float32
opacity float32
fbo float32
_ [2]float32
}
type colorUniforms struct {
@@ -284,7 +317,7 @@ type gradientUniforms struct {
color2 f32color.RGBA
}
type materialType uint8
type clipType uint8
const (
clipTypeNone clipType = iota
@@ -292,6 +325,8 @@ const (
clipTypeIntersection
)
type materialType uint8
const (
materialColor materialType = iota
materialLinearGradient
@@ -391,6 +426,8 @@ func (g *gpu) frame(target RenderTarget) error {
g.coverTimer.begin()
g.renderer.uploadImages(g.cache, g.drawOps.imageOps)
g.renderer.prepareDrawOps(g.cache, g.drawOps.imageOps)
g.drawOps.layers = g.renderer.packLayers(g.drawOps.layers)
g.renderer.drawLayers(g.cache, g.drawOps.layers, g.drawOps.imageOps)
d := driver.LoadDesc{
ClearColor: g.drawOps.clearColor,
}
@@ -400,7 +437,7 @@ func (g *gpu) frame(target RenderTarget) error {
}
g.ctx.BeginRenderPass(defFBO, d)
g.ctx.Viewport(0, 0, viewport.X, viewport.Y)
g.renderer.drawOps(g.cache, g.drawOps.imageOps)
g.renderer.drawOps(g.cache, false, image.Point{}, g.renderer.blitter.viewport, g.drawOps.imageOps)
g.coverTimer.end()
g.ctx.EndRenderPass()
g.cleanupTimer.begin()
@@ -424,19 +461,41 @@ func (g *gpu) Profile() string {
}
func (r *renderer) texHandle(cache *resourceCache, data imageOpData) driver.Texture {
type cachekey struct {
filter byte
handle any
}
key := cachekey{
filter: data.filter,
handle: data.handle,
}
var tex *texture
t, exists := cache.get(data.handle)
t, exists := cache.get(key)
if !exists {
t = &texture{
src: data.src,
}
cache.put(data.handle, t)
cache.put(key, t)
}
tex = t.(*texture)
if tex.tex != nil {
return tex.tex
}
handle, err := r.ctx.NewTexture(driver.TextureFormatSRGBA, data.src.Bounds().Dx(), data.src.Bounds().Dy(), driver.FilterLinearMipmapLinear, driver.FilterLinear, driver.BufferBindingTexture)
var minFilter, magFilter driver.TextureFilter
switch data.filter {
case filterLinear:
minFilter, magFilter = driver.FilterLinearMipmapLinear, driver.FilterLinear
case filterNearest:
minFilter, magFilter = driver.FilterNearest, driver.FilterNearest
}
handle, err := r.ctx.NewTexture(driver.TextureFormatSRGBA,
data.src.Bounds().Dx(), data.src.Bounds().Dy(),
minFilter, magFilter,
driver.BufferBindingTexture,
)
if err != nil {
panic(err)
}
@@ -464,15 +523,18 @@ func newRenderer(ctx driver.Device) *renderer {
if cap := 8192; maxDim > cap {
maxDim = cap
}
d := image.Pt(maxDim, maxDim)
r.packer.maxDims = image.Pt(maxDim, maxDim)
r.intersections.maxDims = image.Pt(maxDim, maxDim)
r.packer.maxDims = d
r.intersections.maxDims = d
r.layers.maxDims = d
return r
}
func (r *renderer) release() {
r.pather.release()
r.blitter.release()
r.layerFBOs.delete(r.ctx, 0)
}
func newBlitter(ctx driver.Device) *blitter {
@@ -747,8 +809,7 @@ func (r *renderer) packStencils(pops *[]*pathOp) {
ops = ops[:len(ops)-1]
continue
}
sz := image.Point{X: p.clip.Dx(), Y: p.clip.Dy()}
place, ok := r.packer.add(sz)
place, ok := r.packer.add(p.clip.Size())
if !ok {
// The clip area is at most the entire screen. Hopefully no
// screen is larger than GL_MAX_TEXTURE_SIZE.
@@ -760,6 +821,83 @@ func (r *renderer) packStencils(pops *[]*pathOp) {
*pops = ops
}
func (r *renderer) packLayers(layers []opacityLayer) []opacityLayer {
// Make every layer bounds contain nested layers; cull empty layers.
for i := len(layers) - 1; i >= 0; i-- {
l := layers[i]
if l.parent != -1 {
b := layers[l.parent].clip
layers[l.parent].clip = b.Union(l.clip)
}
if l.clip.Empty() {
layers = append(layers[:i], layers[i+1:]...)
}
}
// Pack layers.
r.layers.clear()
depth := 0
for i := range layers {
l := &layers[i]
// Only layers of the same depth may be packed together.
if l.depth != depth {
r.layers.newPage()
}
place, ok := r.layers.add(l.clip.Size())
if !ok {
// The layer area is at most the entire screen. Hopefully no
// screen is larger than GL_MAX_TEXTURE_SIZE.
panic(fmt.Errorf("layer size %v is larger than maximum texture size %v", l.clip.Size(), r.layers.maxDims))
}
l.place = place
}
return layers
}
func (r *renderer) drawLayers(cache *resourceCache, layers []opacityLayer, ops []imageOp) {
if len(r.layers.sizes) == 0 {
return
}
fbo := -1
r.layerFBOs.resize(r.ctx, driver.TextureFormatSRGBA, r.layers.sizes)
for i := len(layers) - 1; i >= 0; i-- {
l := layers[i]
if fbo != l.place.Idx {
if fbo != -1 {
r.ctx.EndRenderPass()
r.ctx.PrepareTexture(r.layerFBOs.fbos[fbo].tex)
}
fbo = l.place.Idx
f := r.layerFBOs.fbos[fbo]
r.ctx.BeginRenderPass(f.tex, driver.LoadDesc{Action: driver.LoadActionClear})
}
v := image.Rectangle{
Min: l.place.Pos,
Max: l.place.Pos.Add(l.clip.Size()),
}
r.ctx.Viewport(v.Min.X, v.Min.Y, v.Max.X, v.Max.Y)
f := r.layerFBOs.fbos[fbo]
r.drawOps(cache, true, l.clip.Min.Mul(-1), l.clip.Size(), ops[l.opStart:l.opEnd])
sr := f32.FRect(v)
uvScale, uvOffset := texSpaceTransform(sr, f.size)
uvTrans := f32.Affine2D{}.Scale(f32.Point{}, uvScale).Offset(uvOffset)
// Replace layer ops with one textured op.
ops[l.opStart] = imageOp{
clip: l.clip,
material: material{
material: materialTexture,
tex: f.tex,
uvTrans: uvTrans,
opacity: l.opacity,
},
layerOps: l.opEnd - l.opStart - 1,
}
}
if fbo != -1 {
r.ctx.EndRenderPass()
r.ctx.PrepareTexture(r.layerFBOs.fbos[fbo].tex)
}
}
func (d *drawOps) reset(viewport image.Point) {
d.profile = false
d.viewport = viewport
@@ -768,6 +906,8 @@ func (d *drawOps) reset(viewport image.Point) {
d.pathOpCache = d.pathOpCache[:0]
d.vertCache = d.vertCache[:0]
d.transStack = d.transStack[:0]
d.layers = d.layers[:0]
d.opacityStack = d.opacityStack[:0]
}
func (d *drawOps) collect(root *op.Ops, viewport image.Point) {
@@ -866,6 +1006,27 @@ loop:
state.t = d.transStack[n-1]
d.transStack = d.transStack[:n-1]
case ops.TypePushOpacity:
opacity := ops.DecodeOpacity(encOp.Data)
parent := -1
depth := len(d.opacityStack)
if depth > 0 {
parent = d.opacityStack[depth-1]
}
lidx := len(d.layers)
d.layers = append(d.layers, opacityLayer{
opacity: opacity,
parent: parent,
depth: depth,
opStart: len(d.imageOps),
})
d.opacityStack = append(d.opacityStack, lidx)
case ops.TypePopOpacity:
n := len(d.opacityStack)
idx := d.opacityStack[n-1]
d.layers[idx].opEnd = len(d.imageOps)
d.opacityStack = d.opacityStack[:n-1]
case ops.TypeStroke:
quads.key.strokeWidth = decodeStrokeOp(encOp.Data)
@@ -958,7 +1119,7 @@ loop:
mat := state.materialFor(bnd, off, partialTrans, bounds)
rect := state.cpath == nil || state.cpath.rect
if bounds.Min == (image.Point{}) && bounds.Max == d.viewport && rect && mat.opaque && (mat.material == materialColor) {
if bounds.Min == (image.Point{}) && bounds.Max == d.viewport && rect && mat.opaque && (mat.material == materialColor) && len(d.opacityStack) == 0 {
// The image is a uniform opaque color and takes up the whole screen.
// Scrap images up to and including this image and set clear color.
d.imageOps = d.imageOps[:0]
@@ -971,6 +1132,15 @@ loop:
clip: bounds,
material: mat,
}
if n := len(d.opacityStack); n > 0 {
idx := d.opacityStack[n-1]
lb := d.layers[idx].clip
if lb.Empty() {
d.layers[idx].clip = img.clip
} else {
d.layers[idx].clip = lb.Union(img.clip)
}
}
d.imageOps = append(d.imageOps, img)
if clipData != nil {
@@ -1000,7 +1170,9 @@ func expandPathOp(p *pathOp, clip image.Rectangle) {
}
func (d *drawState) materialFor(rect f32.Rectangle, off f32.Point, partTrans f32.Affine2D, clip image.Rectangle) material {
var m material
m := material{
opacity: 1.,
}
switch d.matType {
case materialColor:
m.material = materialColor
@@ -1040,10 +1212,11 @@ func (d *drawState) materialFor(rect f32.Rectangle, off f32.Point, partTrans f32
}
func (r *renderer) uploadImages(cache *resourceCache, ops []imageOp) {
for _, img := range ops {
for i := range ops {
img := &ops[i]
m := img.material
if m.material == materialTexture {
r.texHandle(cache, m.data)
img.material.tex = r.texHandle(cache, m.data)
}
}
}
@@ -1053,10 +1226,10 @@ func (r *renderer) prepareDrawOps(cache *resourceCache, ops []imageOp) {
m := img.material
switch m.material {
case materialTexture:
r.ctx.PrepareTexture(r.texHandle(cache, m.data))
r.ctx.PrepareTexture(m.tex)
}
var fbo stencilFBO
var fbo FBO
switch img.clipType {
case clipTypeNone:
continue
@@ -1069,24 +1242,26 @@ func (r *renderer) prepareDrawOps(cache *resourceCache, ops []imageOp) {
}
}
func (r *renderer) drawOps(cache *resourceCache, ops []imageOp) {
func (r *renderer) drawOps(cache *resourceCache, isFBO bool, opOff image.Point, viewport image.Point, ops []imageOp) {
var coverTex driver.Texture
for _, img := range ops {
for i := 0; i < len(ops); i++ {
img := ops[i]
i += img.layerOps
m := img.material
switch m.material {
case materialTexture:
r.ctx.BindTexture(0, r.texHandle(cache, m.data))
r.ctx.BindTexture(0, m.tex)
}
drc := img.clip
drc := img.clip.Add(opOff)
scale, off := clipSpaceTransform(drc, r.blitter.viewport)
var fbo stencilFBO
scale, off := clipSpaceTransform(drc, viewport)
var fbo FBO
switch img.clipType {
case clipTypeNone:
p := r.blitter.pipelines[m.material]
r.ctx.BindPipeline(p.pipeline)
r.ctx.BindVertexBuffer(r.blitter.quadVerts, 0)
r.blitter.blit(m.material, m.color, m.color1, m.color2, scale, off, m.uvTrans)
r.blitter.blit(m.material, isFBO, m.color, m.color1, m.color2, scale, off, m.opacity, m.uvTrans)
continue
case clipTypePath:
fbo = r.pather.stenciler.cover(img.place.Idx)
@@ -1105,11 +1280,11 @@ func (r *renderer) drawOps(cache *resourceCache, ops []imageOp) {
p := r.pather.coverer.pipelines[m.material]
r.ctx.BindPipeline(p.pipeline)
r.ctx.BindVertexBuffer(r.blitter.quadVerts, 0)
r.pather.cover(m.material, m.color, m.color1, m.color2, scale, off, m.uvTrans, coverScale, coverOff)
r.pather.cover(m.material, isFBO, m.color, m.color1, m.color2, scale, off, m.uvTrans, coverScale, coverOff)
}
}
func (b *blitter) blit(mat materialType, col f32color.RGBA, col1, col2 f32color.RGBA, scale, off f32.Point, uvTrans f32.Affine2D) {
func (b *blitter) blit(mat materialType, fbo bool, col f32color.RGBA, col1, col2 f32color.RGBA, scale, off f32.Point, opacity float32, uvTrans f32.Affine2D) {
p := b.pipelines[mat]
b.ctx.BindPipeline(p.pipeline)
var uniforms *blitUniforms
@@ -1119,18 +1294,23 @@ func (b *blitter) blit(mat materialType, col f32color.RGBA, col1, col2 f32color.
uniforms = &b.colUniforms.blitUniforms
case materialTexture:
t1, t2, t3, t4, t5, t6 := uvTrans.Elems()
b.texUniforms.blitUniforms.uvTransformR1 = [4]float32{t1, t2, t3, 0}
b.texUniforms.blitUniforms.uvTransformR2 = [4]float32{t4, t5, t6, 0}
uniforms = &b.texUniforms.blitUniforms
uniforms.uvTransformR1 = [4]float32{t1, t2, t3, 0}
uniforms.uvTransformR2 = [4]float32{t4, t5, t6, 0}
case materialLinearGradient:
b.linearGradientUniforms.color1 = col1
b.linearGradientUniforms.color2 = col2
t1, t2, t3, t4, t5, t6 := uvTrans.Elems()
b.linearGradientUniforms.blitUniforms.uvTransformR1 = [4]float32{t1, t2, t3, 0}
b.linearGradientUniforms.blitUniforms.uvTransformR2 = [4]float32{t4, t5, t6, 0}
uniforms = &b.linearGradientUniforms.blitUniforms
uniforms.uvTransformR1 = [4]float32{t1, t2, t3, 0}
uniforms.uvTransformR2 = [4]float32{t4, t5, t6, 0}
}
uniforms.fbo = 0
if fbo {
uniforms.fbo = 1
}
uniforms.opacity = opacity
uniforms.transform = [4]float32{scale.X, scale.Y, off.X, off.Y}
p.UploadUniforms(b.ctx)
b.ctx.DrawArrays(0, 4)
+3 -1
View File
@@ -15,6 +15,7 @@ import (
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/widget/material"
)
@@ -33,7 +34,8 @@ func setupBenchmark(b *testing.B) (layout.Context, *headless.Window, *material.T
Ops: ops,
Constraints: layout.Exact(sz),
}
th := material.NewTheme(gofont.Collection())
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
return gtx, w, th
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

+83
View File
@@ -359,6 +359,73 @@ func TestImageRGBA(t *testing.T) {
})
}
func TestImageRGBA_ScaleLinear(t *testing.T) {
run(t, func(o *op.Ops) {
w := newWindow(t, 128, 128)
defer clip.Rect{Max: image.Pt(128, 128)}.Push(o).Pop()
op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Pt(64, 64))).Add(o)
im := image.NewRGBA(image.Rect(0, 0, 2, 2))
im.Set(0, 0, colornames.Red)
im.Set(1, 0, colornames.Green)
im.Set(0, 1, colornames.White)
im.Set(1, 1, colornames.Black)
op := paint.NewImageOp(im)
op.Filter = paint.FilterLinear
op.Add(o)
paint.PaintOp{}.Add(o)
if err := w.Frame(o); err != nil {
t.Error(err)
}
}, func(r result) {
r.expect(0, 0, colornames.Red)
r.expect(8, 8, colornames.Red)
// TODO: this currently seems to do srgb scaling
// instead of linear rgb scaling,
r.expect(64-4, 0, color.RGBA{R: 197, G: 87, B: 0, A: 255})
r.expect(64+4, 0, color.RGBA{R: 175, G: 98, B: 0, A: 255})
r.expect(127, 0, colornames.Green)
r.expect(127-8, 8, colornames.Green)
})
}
func TestImageRGBA_ScaleNearest(t *testing.T) {
run(t, func(o *op.Ops) {
w := newWindow(t, 128, 128)
op.Affine(f32.Affine2D{}.Scale(f32.Point{}, f32.Pt(64, 64))).Add(o)
im := image.NewRGBA(image.Rect(0, 0, 2, 2))
im.Set(0, 0, colornames.Red)
im.Set(1, 0, colornames.Green)
im.Set(0, 1, colornames.White)
im.Set(1, 1, colornames.Black)
op := paint.NewImageOp(im)
op.Filter = paint.FilterNearest
op.Add(o)
paint.PaintOp{}.Add(o)
if err := w.Frame(o); err != nil {
t.Error(err)
}
}, func(r result) {
r.expect(0, 0, colornames.Red)
r.expect(8, 8, colornames.Red)
r.expect(64-4, 0, colornames.Red)
r.expect(64+4, 0, colornames.Green)
r.expect(127, 0, colornames.Green)
r.expect(127-8, 8, colornames.Green)
})
}
func TestGapsInPath(t *testing.T) {
ops := new(op.Ops)
var p clip.Path
@@ -413,6 +480,22 @@ func TestGapsInPath(t *testing.T) {
})
}
func TestOpacity(t *testing.T) {
run(t, func(ops *op.Ops) {
opc1 := paint.PushOpacity(ops, .3)
// Fill screen to exercize the glClear optimization.
paint.FillShape(ops, color.NRGBA{R: 255, A: 255}, clip.Rect{Max: image.Pt(1024, 1024)}.Op())
opc2 := paint.PushOpacity(ops, .6)
paint.FillShape(ops, color.NRGBA{G: 255, A: 255}, clip.Rect{Min: image.Pt(20, 10), Max: image.Pt(64, 128)}.Op())
opc2.Pop()
opc1.Pop()
opc3 := paint.PushOpacity(ops, .6)
paint.FillShape(ops, color.NRGBA{G: 255, A: 255}, clip.Rect{Min: image.Pt(50+20, 10), Max: image.Pt(50+64, 128)}.Op())
opc3.Pop()
}, func(r result) {
})
}
// lerp calculates linear interpolation with color b and p.
func lerp(a, b f32color.RGBA, p float32) f32color.RGBA {
return f32color.RGBA{
+16 -12
View File
@@ -58,7 +58,7 @@ type coverUniforms struct {
uvCoverTransform [4]float32
uvTransformR1 [4]float32
uvTransformR2 [4]float32
_ float32
fbo float32
}
type stenciler struct {
@@ -90,10 +90,10 @@ type intersectUniforms struct {
}
type fboSet struct {
fbos []stencilFBO
fbos []FBO
}
type stencilFBO struct {
type FBO struct {
size image.Point
tex driver.Texture
}
@@ -247,10 +247,10 @@ func newStenciler(ctx driver.Device) *stenciler {
return st
}
func (s *fboSet) resize(ctx driver.Device, sizes []image.Point) {
func (s *fboSet) resize(ctx driver.Device, format driver.TextureFormat, sizes []image.Point) {
// Add fbos.
for i := len(s.fbos); i < len(sizes); i++ {
s.fbos = append(s.fbos, stencilFBO{})
s.fbos = append(s.fbos, FBO{})
}
// Resize fbos.
for i, sz := range sizes {
@@ -273,7 +273,7 @@ func (s *fboSet) resize(ctx driver.Device, sizes []image.Point) {
if sz.X > max {
sz.X = max
}
tex, err := ctx.NewTexture(driver.TextureFormatFloat, sz.X, sz.Y, driver.FilterNearest, driver.FilterNearest,
tex, err := ctx.NewTexture(format, sz.X, sz.Y, driver.FilterNearest, driver.FilterNearest,
driver.BufferBindingTexture|driver.BufferBindingFramebuffer)
if err != nil {
panic(err)
@@ -340,15 +340,15 @@ func (s *stenciler) beginIntersect(sizes []image.Point) {
// 8 bit coverage is enough, but OpenGL ES only supports single channel
// floating point formats. Replace with GL_RGB+GL_UNSIGNED_BYTE if
// no floating point support is available.
s.intersections.resize(s.ctx, sizes)
s.intersections.resize(s.ctx, driver.TextureFormatFloat, sizes)
}
func (s *stenciler) cover(idx int) stencilFBO {
func (s *stenciler) cover(idx int) FBO {
return s.fbos.fbos[idx]
}
func (s *stenciler) begin(sizes []image.Point) {
s.fbos.resize(s.ctx, sizes)
s.fbos.resize(s.ctx, driver.TextureFormatFloat, sizes)
}
func (s *stenciler) stencilPath(bounds image.Rectangle, offset f32.Point, uv image.Point, data pathData) {
@@ -375,11 +375,11 @@ func (s *stenciler) stencilPath(bounds image.Rectangle, offset f32.Point, uv ima
}
}
func (p *pather) cover(mat materialType, col f32color.RGBA, col1, col2 f32color.RGBA, scale, off f32.Point, uvTrans f32.Affine2D, coverScale, coverOff f32.Point) {
p.coverer.cover(mat, col, col1, col2, scale, off, uvTrans, coverScale, coverOff)
func (p *pather) cover(mat materialType, isFBO bool, col f32color.RGBA, col1, col2 f32color.RGBA, scale, off f32.Point, uvTrans f32.Affine2D, coverScale, coverOff f32.Point) {
p.coverer.cover(mat, isFBO, col, col1, col2, scale, off, uvTrans, coverScale, coverOff)
}
func (c *coverer) cover(mat materialType, col f32color.RGBA, col1, col2 f32color.RGBA, scale, off f32.Point, uvTrans f32.Affine2D, coverScale, coverOff f32.Point) {
func (c *coverer) cover(mat materialType, isFBO bool, col f32color.RGBA, col1, col2 f32color.RGBA, scale, off f32.Point, uvTrans f32.Affine2D, coverScale, coverOff f32.Point) {
var uniforms *coverUniforms
switch mat {
case materialColor:
@@ -399,6 +399,10 @@ func (c *coverer) cover(mat materialType, col f32color.RGBA, col1, col2 f32color
c.texUniforms.uvTransformR2 = [4]float32{t4, t5, t6, 0}
uniforms = &c.texUniforms.coverUniforms
}
uniforms.fbo = 0
if isFBO {
uniforms.fbo = 1
}
uniforms.transform = [4]float32{scale.X, scale.Y, off.X, off.Y}
uniforms.uvCoverTransform = [4]float32{coverScale.X, coverScale.Y, coverOff.X, coverOff.Y}
c.pipelines[mat].UploadUniforms(c.ctx)
+57
View File
@@ -0,0 +1,57 @@
// Package debug provides general debug feature management for Gio, including
// the ability to toggle debug features using the GIODEBUG environment variable.
package debug
import (
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
)
const (
debugVariable = "GIODEBUG"
textSubsystem = "text"
silentFeature = "silent"
)
// Text controls whether the text subsystem has debug logging enabled.
var Text atomic.Bool
var parseOnce sync.Once
// Parse processes the current value of GIODEBUG. If it is unset, it does nothing.
// Otherwise it process its value, printing usage info the stderr if the value is
// not understood. Parse will be automatically invoked when the first application
// window is created, allowing applications to manipulate GIODEBUG programmatically
// before it is parsed.
func Parse() {
parseOnce.Do(func() {
val, ok := os.LookupEnv(debugVariable)
if !ok {
return
}
print := false
silent := false
for _, part := range strings.Split(val, ",") {
switch part {
case textSubsystem:
Text.Store(true)
case silentFeature:
silent = true
default:
print = true
}
}
if print && !silent {
fmt.Fprintf(os.Stderr,
`Usage of %s:
A comma-delimited list of debug subsystems to enable. Currently recognized systems:
- %s: text debug info including system font resolution
- %s: silence this usage message even if GIODEBUG contains invalid content
`, debugVariable, textSubsystem, silentFeature)
}
})
}
+1
View File
@@ -62,6 +62,7 @@ func (c *Context) Release() {
eglDestroyContext(c.disp, c.eglCtx.ctx)
c.eglCtx = nil
}
eglTerminate(c.disp)
c.disp = nilEGLDisplay
}
+69 -26
View File
@@ -14,14 +14,25 @@ import (
type Ops struct {
// version is incremented at each Reset.
version int
version uint32
// data contains the serialized operations.
data []byte
// refs hold external references for operations.
refs []interface{}
// stringRefs provides space for string references, pointers to which will
// be stored in refs. Storing a string directly in refs would cause a heap
// allocation, to store the string header in an interface value. The backing
// array of stringRefs, on the other hand, gets reused between calls to
// reset, making string references free on average.
//
// Appending to stringRefs might reallocate the backing array, which will
// leave pointers to the old array in refs. This temporarily causes a slight
// increase in memory usage, but this, too, amortizes away as the capacity
// of stringRefs approaches its stable maximum.
stringRefs []string
// nextStateID is the id allocated for the next
// StateOp.
nextStateID int
nextStateID uint32
// multipOp indicates a multi-op such as clip.Path is being added.
multipOp bool
@@ -40,9 +51,10 @@ const (
TypeMacro OpType = iota + firstOpIndex
TypeCall
TypeDefer
TypePushTransform
TypeTransform
TypePopTransform
TypePushOpacity
TypePopOpacity
TypeInvalidate
TypeImage
TypePaint
@@ -72,30 +84,30 @@ const (
TypeSemanticDesc
TypeSemanticClass
TypeSemanticSelected
TypeSemanticDisabled
TypeSemanticEnabled
TypeSnippet
TypeSelection
TypeActionInput
)
type StackID struct {
id int
prev int
id uint32
prev uint32
}
// StateOp represents a saved operation snapshot to be restored
// later.
type StateOp struct {
id int
macroID int
id uint32
macroID uint32
ops *Ops
}
// stack tracks the integer identities of stack operations to ensure correct
// pairing of their push and pop methods.
type stack struct {
currentID int
nextID int
currentID uint32
nextID uint32
}
type StackKind uint8
@@ -111,6 +123,7 @@ const (
ClipStack StackKind = iota
TransStack
PassStack
OpacityStack
_StackKind
)
@@ -124,11 +137,12 @@ const (
TypeMacroLen = 1 + 4 + 4
TypeCallLen = 1 + 4 + 4 + 4 + 4
TypeDeferLen = 1
TypePushTransformLen = 1 + 4*6
TypeTransformLen = 1 + 1 + 4*6
TypePopTransformLen = 1
TypePushOpacityLen = 1 + 4
TypePopOpacityLen = 1
TypeRedrawLen = 1 + 8
TypeImageLen = 1
TypeImageLen = 1 + 1
TypePaintLen = 1
TypeColorLen = 1 + 4
TypeLinearGradientLen = 1 + 8*2 + 4*2
@@ -156,7 +170,7 @@ const (
TypeSemanticDescLen = 1
TypeSemanticClassLen = 2
TypeSemanticSelectedLen = 2
TypeSemanticDisabledLen = 2
TypeSemanticEnabledLen = 2
TypeSnippetLen = 1 + 4 + 4
TypeSelectionLen = 1 + 2*4 + 2*4 + 4 + 4
TypeActionInputLen = 1 + 1
@@ -183,8 +197,12 @@ func Reset(o *Ops) {
for i := range o.refs {
o.refs[i] = nil
}
for i := range o.stringRefs {
o.stringRefs[i] = ""
}
o.data = o.data[:0]
o.refs = o.refs[:0]
o.stringRefs = o.stringRefs[:0]
o.nextStateID = 0
o.version++
}
@@ -248,11 +266,11 @@ func AddCall(o *Ops, callOps *Ops, pc PC, end PC) {
bo.PutUint32(data[13:], uint32(end.refs))
}
func PushOp(o *Ops, kind StackKind) (StackID, int) {
func PushOp(o *Ops, kind StackKind) (StackID, uint32) {
return o.stacks[kind].push(), o.macroStack.currentID
}
func PopOp(o *Ops, kind StackKind, sid StackID, macroID int) {
func PopOp(o *Ops, kind StackKind, sid StackID, macroID uint32) {
if o.macroStack.currentID != macroID {
panic("stack push and pop must not cross macro boundary")
}
@@ -265,12 +283,26 @@ func Write1(o *Ops, n int, ref1 interface{}) []byte {
return o.data[len(o.data)-n:]
}
func Write1String(o *Ops, n int, ref1 string) []byte {
o.data = append(o.data, make([]byte, n)...)
o.stringRefs = append(o.stringRefs, ref1)
o.refs = append(o.refs, &o.stringRefs[len(o.stringRefs)-1])
return o.data[len(o.data)-n:]
}
func Write2(o *Ops, n int, ref1, ref2 interface{}) []byte {
o.data = append(o.data, make([]byte, n)...)
o.refs = append(o.refs, ref1, ref2)
return o.data[len(o.data)-n:]
}
func Write2String(o *Ops, n int, ref1 interface{}, ref2 string) []byte {
o.data = append(o.data, make([]byte, n)...)
o.stringRefs = append(o.stringRefs, ref2)
o.refs = append(o.refs, ref1, &o.stringRefs[len(o.stringRefs)-1])
return o.data[len(o.data)-n:]
}
func Write3(o *Ops, n int, ref1, ref2, ref3 interface{}) []byte {
o.data = append(o.data, make([]byte, n)...)
o.refs = append(o.refs, ref1, ref2, ref3)
@@ -278,7 +310,7 @@ func Write3(o *Ops, n int, ref1, ref2, ref3 interface{}) []byte {
}
func PCFor(o *Ops) PC {
return PC{data: len(o.data), refs: len(o.refs)}
return PC{data: uint32(len(o.data)), refs: uint32(len(o.refs))}
}
func (s *stack) push() StackID {
@@ -354,6 +386,14 @@ func DecodeTransform(data []byte) (t f32.Affine2D, push bool) {
return f32.NewAffine2D(a, b, c, d, e, f), push
}
func DecodeOpacity(data []byte) float32 {
if OpType(data[0]) != TypePushOpacity {
panic("invalid op")
}
bo := binary.LittleEndian
return math.Float32frombits(bo.Uint32(data[1:]))
}
// DecodeSave decodes the state id of a save op.
func DecodeSave(data []byte) int {
if OpType(data[0]) != TypeSave {
@@ -381,9 +421,10 @@ var opProps = [0x100]opProp{
TypeMacro: {Size: TypeMacroLen, NumRefs: 0},
TypeCall: {Size: TypeCallLen, NumRefs: 1},
TypeDefer: {Size: TypeDeferLen, NumRefs: 0},
TypePushTransform: {Size: TypePushTransformLen, NumRefs: 0},
TypeTransform: {Size: TypeTransformLen, NumRefs: 0},
TypePopTransform: {Size: TypePopTransformLen, NumRefs: 0},
TypePushOpacity: {Size: TypePushOpacityLen, NumRefs: 0},
TypePopOpacity: {Size: TypePopOpacityLen, NumRefs: 0},
TypeInvalidate: {Size: TypeRedrawLen, NumRefs: 0},
TypeImage: {Size: TypeImageLen, NumRefs: 2},
TypePaint: {Size: TypePaintLen, NumRefs: 0},
@@ -413,23 +454,23 @@ var opProps = [0x100]opProp{
TypeSemanticDesc: {Size: TypeSemanticDescLen, NumRefs: 1},
TypeSemanticClass: {Size: TypeSemanticClassLen, NumRefs: 0},
TypeSemanticSelected: {Size: TypeSemanticSelectedLen, NumRefs: 0},
TypeSemanticDisabled: {Size: TypeSemanticDisabledLen, NumRefs: 0},
TypeSemanticEnabled: {Size: TypeSemanticEnabledLen, NumRefs: 0},
TypeSnippet: {Size: TypeSnippetLen, NumRefs: 2},
TypeSelection: {Size: TypeSelectionLen, NumRefs: 1},
TypeActionInput: {Size: TypeActionInputLen, NumRefs: 0},
}
func (t OpType) props() (size, numRefs int) {
func (t OpType) props() (size, numRefs uint32) {
v := opProps[t]
return int(v.Size), int(v.NumRefs)
return uint32(v.Size), uint32(v.NumRefs)
}
func (t OpType) Size() int {
return int(opProps[t].Size)
func (t OpType) Size() uint32 {
return uint32(opProps[t].Size)
}
func (t OpType) NumRefs() int {
return int(opProps[t].NumRefs)
func (t OpType) NumRefs() uint32 {
return uint32(opProps[t].NumRefs)
}
func (t OpType) String() string {
@@ -440,12 +481,14 @@ func (t OpType) String() string {
return "Call"
case TypeDefer:
return "Defer"
case TypePushTransform:
return "PushTransform"
case TypeTransform:
return "Transform"
case TypePopTransform:
return "PopTransform"
case TypePushOpacity:
return "PushOpacity"
case TypePopOpacity:
return "PopOpacity"
case TypeInvalidate:
return "Invalidate"
case TypeImage:
+13 -13
View File
@@ -26,8 +26,8 @@ type EncodedOp struct {
// Key is a unique key for a given op.
type Key struct {
ops *Ops
pc int
version int
pc uint32
version uint32
}
// Shadow of op.MacroOp.
@@ -39,8 +39,8 @@ type macroOp struct {
// PC is an instruction counter for an operation list.
type PC struct {
data int
refs int
data uint32
refs uint32
}
type macro struct {
@@ -128,7 +128,7 @@ func (r *Reader) Decode() (EncodedOp, bool) {
if nrefs != 1 {
panic("internal error: unexpected number of macro refs")
}
deferData := Write1(&r.deferOps, n, refs[0])
deferData := Write1(&r.deferOps, int(n), refs[0])
copy(deferData, data)
r.pc.data += n
r.pc.refs += nrefs
@@ -154,8 +154,8 @@ func (r *Reader) Decode() (EncodedOp, bool) {
r.pc = op.endpc
} else {
// Treat an incomplete macro as containing all remaining ops.
r.pc.data = len(r.ops.data)
r.pc.refs = len(r.ops.refs)
r.pc.data = uint32(len(r.ops.data))
r.pc.refs = uint32(len(r.ops.refs))
}
continue
}
@@ -171,8 +171,8 @@ func (op *opMacroDef) decode(data []byte) {
}
bo := binary.LittleEndian
data = data[:TypeMacroLen]
op.endpc.data = int(int32(bo.Uint32(data[1:])))
op.endpc.refs = int(int32(bo.Uint32(data[5:])))
op.endpc.data = bo.Uint32(data[1:])
op.endpc.refs = bo.Uint32(data[5:])
}
func (m *macroOp) decode(data []byte, refs []interface{}) {
@@ -183,8 +183,8 @@ func (m *macroOp) decode(data []byte, refs []interface{}) {
data = data[:TypeCallLen]
m.ops = refs[0].(*Ops)
m.start.data = int(int32(bo.Uint32(data[1:])))
m.start.refs = int(int32(bo.Uint32(data[5:])))
m.end.data = int(int32(bo.Uint32(data[9:])))
m.end.refs = int(int32(bo.Uint32(data[13:])))
m.start.data = bo.Uint32(data[1:])
m.start.refs = bo.Uint32(data[5:])
m.end.data = bo.Uint32(data[9:])
m.end.refs = bo.Uint32(data[13:])
}
+1 -1
View File
@@ -30,7 +30,7 @@ func (h ReadOp) Add(o *op.Ops) {
}
func (h WriteOp) Add(o *op.Ops) {
data := ops.Write1(&o.Internal, ops.TypeClipboardWriteLen, &h.Text)
data := ops.Write1String(&o.Internal, ops.TypeClipboardWriteLen, h.Text)
data[0] = byte(ops.TypeClipboardWrite)
}
+4 -3
View File
@@ -153,6 +153,8 @@ const (
HintURL
// HintTelephone hints that telephone number input is expected. It may activate shortcuts for 0-9, "#" and "*".
HintTelephone
// HintPassword hints that password input is expected. It may disable autocorrection and enable password autofill.
HintPassword
)
// State is the state of a key during an event.
@@ -321,8 +323,7 @@ func (h InputOp) Add(o *op.Ops) {
if h.Tag == nil {
panic("Tag must be non-nil")
}
filter := h.Keys
data := ops.Write2(&o.Internal, ops.TypeKeyInputLen, h.Tag, &filter)
data := ops.Write2String(&o.Internal, ops.TypeKeyInputLen, h.Tag, string(h.Keys))
data[0] = byte(ops.TypeKeyInput)
data[1] = byte(h.Hint)
}
@@ -341,7 +342,7 @@ func (h FocusOp) Add(o *op.Ops) {
}
func (s SnippetOp) Add(o *op.Ops) {
data := ops.Write2(&o.Internal, ops.TypeSnippetLen, s.Tag, &s.Text)
data := ops.Write2String(&o.Internal, ops.TypeSnippetLen, s.Tag, s.Text)
data[0] = byte(ops.TypeSnippet)
bo := binary.LittleEndian
bo.PutUint32(data[1:], uint32(s.Range.Start))
+2 -2
View File
@@ -8,7 +8,7 @@ object such as a finger.
The InputOp operation is used to declare a handler ready for pointer
events. Use an event.Queue to receive events.
# Types
# Kinds
Only events that match a specified list of types are delivered to a handler.
@@ -20,7 +20,7 @@ Leave, or Scroll):
pointer.InputOp{
Tag: h,
Types: pointer.Press | pointer.Drag | pointer.Release,
Kinds: pointer.Press | pointer.Drag | pointer.Release,
}.Add(ops)
Cancel events are always delivered.
+16 -14
View File
@@ -18,7 +18,7 @@ import (
// Event is a pointer event.
type Event struct {
Type Type
Kind Kind
Source Source
// PointerID is the id for the pointer and can be used
// to track a particular pointer from Press to
@@ -32,8 +32,10 @@ type Event struct {
Time time.Duration
// Buttons are the set of pressed mouse buttons for this event.
Buttons Buttons
// Position is the position of the event, relative to
// the current transformation, as set by op.TransformOp.
// Position is the coordinates of the event in the local coordinate
// system of the receiving tag. The transformation from global window
// coordinates to local coordinates is performed by the inverse of
// the effective transformation of the tag.
Position f32.Point
// Scroll is the scroll amount, if any.
Scroll f32.Point
@@ -51,7 +53,7 @@ type PassOp struct {
type PassStack struct {
ops *ops.Ops
id ops.StackID
macroID int
macroID uint32
}
// InputOp declares an input handler ready for pointer
@@ -61,8 +63,8 @@ type InputOp struct {
// Grab, if set, request that the handler get
// Grabbed priority.
Grab bool
// Types is a bitwise-or of event types to receive.
Types Type
// Kinds is a bitwise-or of event types to receive.
Kinds Kind
// ScrollBounds describe the maximum scrollable distances in both
// axes. Specifically, any Event e delivered to Tag will satisfy
//
@@ -73,8 +75,8 @@ type InputOp struct {
type ID uint16
// Type of an Event.
type Type uint
// Kind of an Event.
type Kind uint
// Priority of an Event.
type Priority uint8
@@ -169,7 +171,7 @@ const (
const (
// A Cancel event is generated when the current gesture is
// interrupted by other handlers or the system.
Cancel Type = (1 << iota) >> 1
Cancel Kind = (1 << iota) >> 1
// Press of a pointer.
Press
// Release of a pointer.
@@ -243,7 +245,7 @@ func (op InputOp) Add(o *op.Ops) {
if b := op.ScrollBounds; b.Min.X > 0 || b.Max.X < 0 || b.Min.Y > 0 || b.Max.Y < 0 {
panic(fmt.Errorf("invalid scroll range value %v", b))
}
if op.Types>>16 > 0 {
if op.Kinds>>16 > 0 {
panic(fmt.Errorf("value in Types overflows uint16"))
}
data := ops.Write1(&o.Internal, ops.TypePointerInputLen, op.Tag)
@@ -252,19 +254,19 @@ func (op InputOp) Add(o *op.Ops) {
data[1] = 1
}
bo := binary.LittleEndian
bo.PutUint16(data[2:], uint16(op.Types))
bo.PutUint16(data[2:], uint16(op.Kinds))
bo.PutUint32(data[4:], uint32(op.ScrollBounds.Min.X))
bo.PutUint32(data[8:], uint32(op.ScrollBounds.Min.Y))
bo.PutUint32(data[12:], uint32(op.ScrollBounds.Max.X))
bo.PutUint32(data[16:], uint32(op.ScrollBounds.Max.Y))
}
func (t Type) String() string {
func (t Kind) String() string {
if t == Cancel {
return "Cancel"
}
var buf strings.Builder
for tt := Type(1); tt > 0; tt <<= 1 {
for tt := Kind(1); tt > 0; tt <<= 1 {
if t&tt > 0 {
if buf.Len() > 0 {
buf.WriteByte('|')
@@ -275,7 +277,7 @@ func (t Type) String() string {
return buf.String()
}
func (t Type) string() string {
func (t Kind) string() string {
switch t {
case Press:
return "Press"
+1 -1
View File
@@ -8,7 +8,7 @@ import (
func TestTypeString(t *testing.T) {
for _, tc := range []struct {
typ Type
typ Kind
res string
}{
{Cancel, "Cancel"},
+2 -7
View File
@@ -30,11 +30,6 @@ func TestKeyWakeup(t *testing.T) {
if evts := r.Events(handler); len(evts) != 1 {
t.Errorf("no Focus event for newly registered key.InputOp")
}
// Verify that r.Events does trigger a redraw.
r.Frame(&ops)
if _, wake := r.WakeupTime(); !wake {
t.Errorf("key.FocusEvent event didn't trigger a redraw")
}
}
func TestKeyMultiples(t *testing.T) {
@@ -279,7 +274,7 @@ func TestFocusScroll(t *testing.T) {
key.InputOp{Tag: h}.Add(ops)
pointer.InputOp{
Tag: h,
Types: pointer.Scroll,
Kinds: pointer.Scroll,
ScrollBounds: image.Rect(-100, -100, 100, 100),
}.Add(ops)
// Test that h is scrolled even if behind another handler.
@@ -305,7 +300,7 @@ func TestFocusClick(t *testing.T) {
key.InputOp{Tag: h}.Add(ops)
pointer.InputOp{
Tag: h,
Types: pointer.Press | pointer.Release,
Kinds: pointer.Press | pointer.Release,
}.Add(ops)
cl.Pop()
r.Frame(ops)
+58 -44
View File
@@ -66,7 +66,7 @@ type pointerHandler struct {
area int
active bool
wantsGrab bool
types pointer.Type
types pointer.Kind
// min and max horizontal/vertical scroll
scrollRange image.Rectangle
@@ -242,7 +242,7 @@ func (c *pointerCollector) newHandler(tag event.Tag, events *handlerEvents) *poi
c.q.handlers[tag] = h
// Cancel handlers on (each) first appearance, but don't
// trigger redraw.
events.AddNoRedraw(tag, pointer.Event{Type: pointer.Cancel})
events.AddNoRedraw(tag, pointer.Event{Kind: pointer.Cancel})
}
h.active = true
h.area = areaID
@@ -268,16 +268,16 @@ func (c *pointerCollector) inputOp(op pointer.InputOp, events *handlerEvents) {
areaID := c.currentArea()
area := &c.q.areas[areaID]
area.semantic.content.tag = op.Tag
if op.Types&(pointer.Press|pointer.Release) != 0 {
if op.Kinds&(pointer.Press|pointer.Release) != 0 {
area.semantic.content.gestures |= ClickGesture
}
if op.Types&pointer.Scroll != 0 {
if op.Kinds&pointer.Scroll != 0 {
area.semantic.content.gestures |= ScrollGesture
}
area.semantic.valid = area.semantic.content.gestures != 0
h := c.newHandler(op.Tag, events)
h.wantsGrab = h.wantsGrab || op.Grab
h.types = h.types | op.Types
h.types = h.types | op.Kinds
h.scrollRange = op.ScrollBounds
}
@@ -309,11 +309,11 @@ func (c *pointerCollector) semanticSelected(selected bool) {
area.semantic.content.selected = selected
}
func (c *pointerCollector) semanticDisabled(disabled bool) {
func (c *pointerCollector) semanticEnabled(enabled bool) {
areaID := c.currentArea()
area := &c.q.areas[areaID]
area.semantic.valid = true
area.semantic.content.disabled = disabled
area.semantic.content.disabled = !enabled
}
func (c *pointerCollector) cursor(cursor pointer.Cursor) {
@@ -432,39 +432,42 @@ func (q *pointerQueue) semanticIDFor(content semanticContent) SemanticID {
return id.id
}
func (q *pointerQueue) ActionAt(pos f32.Point) (system.Action, bool) {
for i := len(q.hitTree) - 1; i >= 0; i-- {
n := &q.hitTree[i]
hit, _ := q.hit(n.area, pos)
if !hit {
continue
}
func (q *pointerQueue) ActionAt(pos f32.Point) (action system.Action, hasAction bool) {
q.hitTest(pos, func(n *hitNode) bool {
area := q.areas[n.area]
return area.action, area.action != 0
}
return 0, false
if area.action != 0 {
action = area.action
hasAction = true
return false
}
return true
})
return action, hasAction
}
func (q *pointerQueue) SemanticAt(pos f32.Point) (SemanticID, bool) {
func (q *pointerQueue) SemanticAt(pos f32.Point) (semID SemanticID, hasSemID bool) {
q.assignSemIDs()
for i := len(q.hitTree) - 1; i >= 0; i-- {
n := &q.hitTree[i]
hit, _ := q.hit(n.area, pos)
if !hit {
continue
}
q.hitTest(pos, func(n *hitNode) bool {
area := q.areas[n.area]
if area.semantic.id != 0 {
return area.semantic.id, true
semID = area.semantic.id
hasSemID = true
return false
}
}
return 0, false
return true
})
return semID, hasSemID
}
func (q *pointerQueue) opHit(pos f32.Point) ([]event.Tag, pointer.Cursor) {
// hitTest searches the hit tree for nodes matching pos. Any node matching pos will
// have the onNode func invoked on it to allow the caller to extract whatever information
// is necessary for further processing. onNode may return false to terminate the walk of
// the hit tree, or true to continue. Providing this algorithm in this generic way
// allows normal event routing and system action event routing to share the same traversal
// logic even though they are interested in different aspects of hit nodes.
func (q *pointerQueue) hitTest(pos f32.Point, onNode func(*hitNode) bool) pointer.Cursor {
// Track whether we're passing through hits.
pass := true
hits := q.scratch[:0]
idx := len(q.hitTree) - 1
cursor := pointer.CursorDefault
for idx >= 0 {
@@ -483,12 +486,23 @@ func (q *pointerQueue) opHit(pos f32.Point) ([]event.Tag, pointer.Cursor) {
} else {
idx = n.next
}
if !onNode(n) {
break
}
}
return cursor
}
func (q *pointerQueue) opHit(pos f32.Point) ([]event.Tag, pointer.Cursor) {
hits := q.scratch[:0]
cursor := q.hitTest(pos, func(n *hitNode) bool {
if n.tag != nil {
if _, exists := q.handlers[n.tag]; exists {
hits = addHandler(hits, n.tag)
}
}
}
return true
})
q.scratch = hits[:0]
return hits, cursor
}
@@ -588,7 +602,7 @@ func (q *pointerQueue) Frame(events *handlerEvents) {
func (q *pointerQueue) dropHandler(events *handlerEvents, tag event.Tag) {
if events != nil {
events.Add(tag, pointer.Event{Type: pointer.Cancel})
events.Add(tag, pointer.Event{Kind: pointer.Cancel})
}
for i := range q.pointers {
p := &q.pointers[i]
@@ -635,11 +649,11 @@ func (q *pointerQueue) Deliver(areaIdx int, e pointer.Event, events *handlerEven
continue
}
h := q.handlers[n.tag]
if e.Type&h.types == 0 {
if e.Kind&h.types == 0 {
continue
}
e := e
if e.Type == pointer.Scroll {
if e.Kind == pointer.Scroll {
if sx == 0 && sy == 0 {
break
}
@@ -649,7 +663,7 @@ func (q *pointerQueue) Deliver(areaIdx int, e pointer.Event, events *handlerEven
}
e.Position = q.invTransform(h.area, e.Position)
events.Add(n.tag, e)
if e.Type != pointer.Scroll {
if e.Kind != pointer.Scroll {
break
}
}
@@ -669,7 +683,7 @@ func (q *pointerQueue) SemanticArea(areaIdx int) (semanticContent, int) {
}
func (q *pointerQueue) Push(e pointer.Event, events *handlerEvents) {
if e.Type == pointer.Cancel {
if e.Kind == pointer.Cancel {
q.pointers = q.pointers[:0]
for k := range q.handlers {
q.dropHandler(events, k)
@@ -680,14 +694,14 @@ func (q *pointerQueue) Push(e pointer.Event, events *handlerEvents) {
p := &q.pointers[pidx]
p.last = e
switch e.Type {
switch e.Kind {
case pointer.Press:
q.deliverEnterLeaveEvents(p, events, e)
p.pressed = true
q.deliverEvent(p, events, e)
case pointer.Move:
if p.pressed {
e.Type = pointer.Drag
e.Kind = pointer.Drag
}
q.deliverEnterLeaveEvents(p, events, e)
q.deliverEvent(p, events, e)
@@ -721,7 +735,7 @@ func (q *pointerQueue) deliverEvent(p *pointerInfo, events *handlerEvents, e poi
var sx, sy = e.Scroll.X, e.Scroll.Y
for _, k := range p.handlers {
h := q.handlers[k]
if e.Type == pointer.Scroll {
if e.Kind == pointer.Scroll {
if sx == 0 && sy == 0 {
return
}
@@ -729,7 +743,7 @@ func (q *pointerQueue) deliverEvent(p *pointerInfo, events *handlerEvents, e poi
sx, e.Scroll.X = setScrollEvent(sx, h.scrollRange.Min.X, h.scrollRange.Max.X)
sy, e.Scroll.Y = setScrollEvent(sy, h.scrollRange.Min.Y, h.scrollRange.Max.Y)
}
if e.Type&h.types == 0 {
if e.Kind&h.types == 0 {
continue
}
e := e
@@ -744,7 +758,7 @@ func (q *pointerQueue) deliverEvent(p *pointerInfo, events *handlerEvents, e poi
func (q *pointerQueue) deliverEnterLeaveEvents(p *pointerInfo, events *handlerEvents, e pointer.Event) {
var hits []event.Tag
if e.Source != pointer.Mouse && !p.pressed && e.Type != pointer.Press {
if e.Source != pointer.Mouse && !p.pressed && e.Kind != pointer.Press {
// Consider non-mouse pointers leaving when they're released.
} else {
hits, q.cursor = q.opHit(e.Position)
@@ -776,9 +790,9 @@ func (q *pointerQueue) deliverEnterLeaveEvents(p *pointerInfo, events *handlerEv
continue
}
h := q.handlers[k]
e.Type = pointer.Leave
e.Kind = pointer.Leave
if e.Type&h.types != 0 {
if e.Kind&h.types != 0 {
e := e
e.Position = q.invTransform(h.area, e.Position)
events.Add(k, e)
@@ -790,9 +804,9 @@ func (q *pointerQueue) deliverEnterLeaveEvents(p *pointerInfo, events *handlerEv
if _, found := searchTag(p.entered, k); found {
continue
}
e.Type = pointer.Enter
e.Kind = pointer.Enter
if e.Type&h.types != 0 {
if e.Kind&h.types != 0 {
e := e
e.Position = q.invTransform(h.area, e.Position)
events.Add(k, e)
+149 -105
View File
@@ -14,6 +14,7 @@ import (
"gioui.org/io/event"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/io/system"
"gioui.org/io/transfer"
"gioui.org/op"
"gioui.org/op/clip"
@@ -32,11 +33,6 @@ func TestPointerWakeup(t *testing.T) {
}
// However, adding a handler queues a Cancel event.
assertEventPointerTypeSequence(t, r.Events(handler), pointer.Cancel)
// Verify that r.Events does trigger a redraw.
r.Frame(&ops)
if _, wake := r.WakeupTime(); !wake {
t.Errorf("pointer.Cancel event didn't trigger a redraw")
}
}
func TestPointerDrag(t *testing.T) {
@@ -49,12 +45,12 @@ func TestPointerDrag(t *testing.T) {
r.Queue(
// Press.
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(50, 50),
},
// Move outside the area.
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(150, 150),
},
)
@@ -71,12 +67,12 @@ func TestPointerDragNegative(t *testing.T) {
r.Queue(
// Press.
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(-50, -50),
},
// Move outside the area.
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(-150, -150),
},
)
@@ -91,15 +87,15 @@ func TestPointerGrab(t *testing.T) {
types := pointer.Press | pointer.Release
pointer.InputOp{Tag: handler1, Types: types, Grab: true}.Add(&ops)
pointer.InputOp{Tag: handler2, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler3, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler1, Kinds: types, Grab: true}.Add(&ops)
pointer.InputOp{Tag: handler2, Kinds: types}.Add(&ops)
pointer.InputOp{Tag: handler3, Kinds: types}.Add(&ops)
var r Router
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(50, 50),
},
)
@@ -109,7 +105,7 @@ func TestPointerGrab(t *testing.T) {
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(50, 50),
},
)
@@ -125,15 +121,15 @@ func TestPointerGrabSameHandlerTwice(t *testing.T) {
types := pointer.Press | pointer.Release
pointer.InputOp{Tag: handler1, Types: types, Grab: true}.Add(&ops)
pointer.InputOp{Tag: handler1, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler2, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler1, Kinds: types, Grab: true}.Add(&ops)
pointer.InputOp{Tag: handler1, Kinds: types}.Add(&ops)
pointer.InputOp{Tag: handler2, Kinds: types}.Add(&ops)
var r Router
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(50, 50),
},
)
@@ -142,7 +138,7 @@ func TestPointerGrabSameHandlerTwice(t *testing.T) {
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(50, 50),
},
)
@@ -159,10 +155,10 @@ func TestPointerMove(t *testing.T) {
// Handler 1 area: (0, 0) - (100, 100)
r1 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
pointer.InputOp{Tag: handler1, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler1, Kinds: types}.Add(&ops)
// Handler 2 area: (50, 50) - (100, 100) (areas intersect).
r2 := clip.Rect(image.Rect(50, 50, 200, 200)).Push(&ops)
pointer.InputOp{Tag: handler2, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler2, Kinds: types}.Add(&ops)
r2.Pop()
r1.Pop()
@@ -171,21 +167,21 @@ func TestPointerMove(t *testing.T) {
r.Queue(
// Hit both handlers.
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(50, 50),
},
// Hit handler 1.
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(49, 50),
},
// Hit no handlers.
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(100, 50),
},
pointer.Event{
Type: pointer.Cancel,
Kind: pointer.Cancel,
},
)
assertEventPointerTypeSequence(t, r.Events(handler1), pointer.Cancel, pointer.Enter, pointer.Move, pointer.Move, pointer.Leave, pointer.Cancel)
@@ -198,7 +194,7 @@ func TestPointerTypes(t *testing.T) {
r1 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
pointer.InputOp{
Tag: handler,
Types: pointer.Press | pointer.Release,
Kinds: pointer.Press | pointer.Release,
}.Add(&ops)
r1.Pop()
@@ -206,21 +202,58 @@ func TestPointerTypes(t *testing.T) {
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(50, 50),
},
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(150, 150),
},
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(150, 150),
},
)
assertEventPointerTypeSequence(t, r.Events(handler), pointer.Cancel, pointer.Press, pointer.Release)
}
func TestPointerSystemAction(t *testing.T) {
t.Run("simple", func(t *testing.T) {
var ops op.Ops
r1 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
system.ActionInputOp(system.ActionMove).Add(&ops)
r1.Pop()
var r Router
r.Frame(&ops)
assertActionAt(t, r, f32.Pt(50, 50), system.ActionMove)
})
t.Run("covered by another clip", func(t *testing.T) {
var ops op.Ops
r1 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
system.ActionInputOp(system.ActionMove).Add(&ops)
clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops).Pop()
r1.Pop()
var r Router
r.Frame(&ops)
assertActionAt(t, r, f32.Pt(50, 50), system.ActionMove)
})
t.Run("uses topmost action op", func(t *testing.T) {
var ops op.Ops
r1 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
system.ActionInputOp(system.ActionMove).Add(&ops)
r2 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
system.ActionInputOp(system.ActionClose).Add(&ops)
r2.Pop()
r1.Pop()
var r Router
r.Frame(&ops)
assertActionAt(t, r, f32.Pt(50, 50), system.ActionClose)
})
}
func TestPointerPriority(t *testing.T) {
handler1 := new(int)
handler2 := new(int)
@@ -230,14 +263,14 @@ func TestPointerPriority(t *testing.T) {
r1 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
pointer.InputOp{
Tag: handler1,
Types: pointer.Scroll,
Kinds: pointer.Scroll,
ScrollBounds: image.Rectangle{Max: image.Point{X: 100}},
}.Add(&ops)
r2 := clip.Rect(image.Rect(0, 0, 100, 50)).Push(&ops)
pointer.InputOp{
Tag: handler2,
Types: pointer.Scroll,
Kinds: pointer.Scroll,
ScrollBounds: image.Rectangle{Max: image.Point{X: 20}},
}.Add(&ops)
r2.Pop()
@@ -246,7 +279,7 @@ func TestPointerPriority(t *testing.T) {
r3 := clip.Rect(image.Rect(0, 100, 100, 200)).Push(&ops)
pointer.InputOp{
Tag: handler3,
Types: pointer.Scroll,
Kinds: pointer.Scroll,
ScrollBounds: image.Rectangle{Min: image.Point{X: -20, Y: -40}},
}.Add(&ops)
r3.Pop()
@@ -256,25 +289,25 @@ func TestPointerPriority(t *testing.T) {
r.Queue(
// Hit handler 1 and 2.
pointer.Event{
Type: pointer.Scroll,
Kind: pointer.Scroll,
Position: f32.Pt(50, 25),
Scroll: f32.Pt(50, 0),
},
// Hit handler 1.
pointer.Event{
Type: pointer.Scroll,
Kind: pointer.Scroll,
Position: f32.Pt(50, 75),
Scroll: f32.Pt(50, 50),
},
// Hit handler 3.
pointer.Event{
Type: pointer.Scroll,
Kind: pointer.Scroll,
Position: f32.Pt(50, 150),
Scroll: f32.Pt(-30, -30),
},
// Hit no handlers.
pointer.Event{
Type: pointer.Scroll,
Kind: pointer.Scroll,
Position: f32.Pt(50, 225),
},
)
@@ -310,7 +343,7 @@ func TestPointerEnterLeave(t *testing.T) {
// Hit both handlers.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(50, 50),
},
)
@@ -323,7 +356,7 @@ func TestPointerEnterLeave(t *testing.T) {
// Leave the second area by moving into the first.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(45, 45),
},
)
@@ -334,7 +367,7 @@ func TestPointerEnterLeave(t *testing.T) {
// Move, but stay within the same hit area.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(40, 40),
},
)
@@ -344,7 +377,7 @@ func TestPointerEnterLeave(t *testing.T) {
// Move outside of both inputs.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(300, 300),
},
)
@@ -354,7 +387,7 @@ func TestPointerEnterLeave(t *testing.T) {
// Check that a Press event generates Enter Events.
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(125, 125),
},
)
@@ -365,12 +398,12 @@ func TestPointerEnterLeave(t *testing.T) {
r.Queue(
// Leave
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(25, 25),
},
// Enter
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(50, 50),
},
)
@@ -380,7 +413,7 @@ func TestPointerEnterLeave(t *testing.T) {
// Check that a Release event generates Enter/Leave Events.
r.Queue(
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(25,
25),
},
@@ -408,15 +441,15 @@ func TestMultipleAreas(t *testing.T) {
// Hit first area, then second area, then both.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(25, 25),
},
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(150, 150),
},
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(50, 50),
},
)
@@ -432,11 +465,11 @@ func TestPointerEnterLeaveNested(t *testing.T) {
// Handler 1 area: (0, 0) - (100, 100)
r1 := clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops)
pointer.InputOp{Tag: handler1, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler1, Kinds: types}.Add(&ops)
// Handler 2 area: (25, 25) - (75, 75) (nested within first).
r2 := clip.Rect(image.Rect(25, 25, 75, 75)).Push(&ops)
pointer.InputOp{Tag: handler2, Types: types}.Add(&ops)
pointer.InputOp{Tag: handler2, Kinds: types}.Add(&ops)
r2.Pop()
r1.Pop()
@@ -445,7 +478,7 @@ func TestPointerEnterLeaveNested(t *testing.T) {
// Hit both handlers.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(50, 50),
},
)
@@ -457,7 +490,7 @@ func TestPointerEnterLeaveNested(t *testing.T) {
// Leave the second area by moving into the first.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(20, 20),
},
)
@@ -467,7 +500,7 @@ func TestPointerEnterLeaveNested(t *testing.T) {
// Move, but stay within the same hit area.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(10, 10),
},
)
@@ -477,7 +510,7 @@ func TestPointerEnterLeaveNested(t *testing.T) {
// Move outside of both inputs.
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(200, 200),
},
)
@@ -487,7 +520,7 @@ func TestPointerEnterLeaveNested(t *testing.T) {
// Check that a Press event generates Enter Events.
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(50, 50),
},
)
@@ -497,7 +530,7 @@ func TestPointerEnterLeaveNested(t *testing.T) {
// Check that a Release event generates Enter/Leave Events.
r.Queue(
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(20, 20),
},
)
@@ -516,7 +549,7 @@ func TestPointerActiveInputDisappears(t *testing.T) {
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(25, 25),
},
)
@@ -527,7 +560,7 @@ func TestPointerActiveInputDisappears(t *testing.T) {
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(25, 25),
},
)
@@ -549,21 +582,21 @@ func TestMultitouch(t *testing.T) {
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: h1pt,
PointerID: p1,
},
)
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: h2pt,
PointerID: p2,
},
)
r.Queue(
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Position: h2pt,
PointerID: p2,
},
@@ -596,7 +629,7 @@ func TestCursor(t *testing.T) {
_at := func(x, y float32) pointer.Event {
return pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Position: f32.Pt(x, y),
@@ -696,14 +729,14 @@ func TestPassOp(t *testing.T) {
h1, h2, h3, h4 := new(int), new(int), new(int), new(int)
area := clip.Rect(image.Rect(0, 0, 100, 100))
root := area.Push(&ops)
pointer.InputOp{Tag: h1, Types: pointer.Press}.Add(&ops)
pointer.InputOp{Tag: h1, Kinds: pointer.Press}.Add(&ops)
child1 := area.Push(&ops)
pointer.InputOp{Tag: h2, Types: pointer.Press}.Add(&ops)
pointer.InputOp{Tag: h2, Kinds: pointer.Press}.Add(&ops)
child1.Pop()
child2 := area.Push(&ops)
pass := pointer.PassOp{}.Push(&ops)
pointer.InputOp{Tag: h3, Types: pointer.Press}.Add(&ops)
pointer.InputOp{Tag: h4, Types: pointer.Press}.Add(&ops)
pointer.InputOp{Tag: h3, Kinds: pointer.Press}.Add(&ops)
pointer.InputOp{Tag: h4, Kinds: pointer.Press}.Add(&ops)
pass.Pop()
child2.Pop()
root.Pop()
@@ -712,7 +745,7 @@ func TestPassOp(t *testing.T) {
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
},
)
assertEventPointerTypeSequence(t, r.Events(h1), pointer.Cancel, pointer.Press)
@@ -725,13 +758,13 @@ func TestAreaPassthrough(t *testing.T) {
var ops op.Ops
h := new(int)
pointer.InputOp{Tag: h, Types: pointer.Press}.Add(&ops)
pointer.InputOp{Tag: h, Kinds: pointer.Press}.Add(&ops)
clip.Rect(image.Rect(0, 0, 100, 100)).Push(&ops).Pop()
var r Router
r.Frame(&ops)
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
},
)
assertEventPointerTypeSequence(t, r.Events(h), pointer.Cancel, pointer.Press)
@@ -742,7 +775,7 @@ func TestEllipse(t *testing.T) {
h := new(int)
cl := clip.Ellipse(image.Rect(0, 0, 100, 100)).Push(&ops)
pointer.InputOp{Tag: h, Types: pointer.Press}.Add(&ops)
pointer.InputOp{Tag: h, Kinds: pointer.Press}.Add(&ops)
cl.Pop()
var r Router
r.Frame(&ops)
@@ -750,15 +783,15 @@ func TestEllipse(t *testing.T) {
// Outside ellipse.
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
},
// Inside ellipse.
pointer.Event{
Position: f32.Pt(50, 50),
Type: pointer.Press,
Kind: pointer.Press,
},
)
assertEventPointerTypeSequence(t, r.Events(h), pointer.Cancel, pointer.Press)
@@ -787,7 +820,7 @@ func TestTransfer(t *testing.T) {
return src, tgt
}
// Cancel is received when the pointer is first seen.
cancel := pointer.Event{Type: pointer.Cancel}
cancel := pointer.Event{Kind: pointer.Cancel}
t.Run("transfer.Offer should panic on nil Data", func(t *testing.T) {
defer func() {
@@ -807,11 +840,11 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
@@ -821,11 +854,11 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(30, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
pointer.Event{
Position: f32.Pt(30, 10),
Type: pointer.Release,
Kind: pointer.Release,
},
)
assertEventSequence(t, r.Events(src), transfer.CancelEvent{})
@@ -848,11 +881,11 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
@@ -869,11 +902,11 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
@@ -883,7 +916,7 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
Kind: pointer.Release,
},
)
assertEventSequence(t, r.Events(src), transfer.CancelEvent{})
@@ -906,11 +939,11 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
)
assertEventSequence(t, r.Events(src), cancel)
@@ -920,7 +953,7 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
Kind: pointer.Release,
},
)
assertEventSequence(t, r.Events(src), transfer.RequestEvent{Type: "file"})
@@ -973,15 +1006,15 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
Kind: pointer.Release,
},
)
ofr := &offer{data: "hello"}
@@ -1016,15 +1049,15 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
)
assertEventPointerTypeSequence(t, r.Events(&hover), pointer.Cancel, pointer.Enter)
@@ -1033,7 +1066,7 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
Kind: pointer.Release,
},
)
@@ -1064,15 +1097,15 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
)
assertEventPointerTypeSequence(t, r.Events(&hover), pointer.Cancel)
@@ -1081,7 +1114,7 @@ func TestTransfer(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(40, 10),
Type: pointer.Release,
Kind: pointer.Release,
},
)
@@ -1129,7 +1162,7 @@ func TestPassCursor(t *testing.T) {
r.Frame(&ops)
r.Queue(pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Move,
Kind: pointer.Move,
})
if got := r.Cursor(); want != got {
t.Errorf("got cursor %v, want %v", got, want)
@@ -1154,18 +1187,18 @@ func addPointerHandler(ops *op.Ops, tag event.Tag, area image.Rectangle) {
defer clip.Rect(area).Push(ops).Pop()
pointer.InputOp{
Tag: tag,
Types: pointer.Press | pointer.Release | pointer.Move | pointer.Drag | pointer.Enter | pointer.Leave,
Kinds: pointer.Press | pointer.Release | pointer.Move | pointer.Drag | pointer.Enter | pointer.Leave,
}.Add(ops)
}
// pointerTypes converts a sequence of event.Event to their pointer.Types. It assumes
// that all input events are of underlying type pointer.Event, and thus will
// panic if some are not.
func pointerTypes(events []event.Event) []pointer.Type {
var types []pointer.Type
func pointerTypes(events []event.Event) []pointer.Kind {
var types []pointer.Kind
for _, e := range events {
if e, ok := e.(pointer.Event); ok {
types = append(types, e.Type)
types = append(types, e.Kind)
}
}
return types
@@ -1173,7 +1206,7 @@ func pointerTypes(events []event.Event) []pointer.Type {
// assertEventPointerTypeSequence checks that the provided events match the expected pointer event types
// in the provided order.
func assertEventPointerTypeSequence(t *testing.T, events []event.Event, expected ...pointer.Type) {
func assertEventPointerTypeSequence(t *testing.T, events []event.Event, expected ...pointer.Kind) {
t.Helper()
got := pointerTypes(events)
if !reflect.DeepEqual(got, expected) {
@@ -1201,7 +1234,7 @@ func eventsToString(evs []event.Event) string {
for _, ev := range evs {
switch e := ev.(type) {
case pointer.Event:
s = append(s, fmt.Sprintf("%T{%s}", e, e.Type.String()))
s = append(s, fmt.Sprintf("%T{%s}", e, e.Kind.String()))
default:
s = append(s, fmt.Sprintf("{%T}", e))
}
@@ -1231,6 +1264,17 @@ func assertScrollEvent(t *testing.T, ev event.Event, scroll f32.Point) {
}
}
// assertActionAt checks that the router has a system action of the expected type at point.
func assertActionAt(t *testing.T, q Router, point f32.Point, expected system.Action) {
t.Helper()
action, ok := q.ActionAt(point)
if !ok {
t.Errorf("expected action %v at %v, got no action", expected, point)
} else if action != expected {
t.Errorf("expected action %v at %v, got %v", expected, point, action)
}
}
func BenchmarkRouterAdd(b *testing.B) {
// Set this to the number of overlapping handlers that you want to
// evaluate performance for. Typical values for the example applications
@@ -1259,7 +1303,7 @@ func BenchmarkRouterAdd(b *testing.B) {
Push(&ops)
pointer.InputOp{
Tag: handlers[i],
Types: pointer.Move,
Kinds: pointer.Move,
}.Add(&ops)
}
var r Router
@@ -1269,7 +1313,7 @@ func BenchmarkRouterAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
r.Queue(
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(50, 50),
},
)
+11 -21
View File
@@ -273,7 +273,7 @@ func (q *Router) ScrollFocus(dist image.Point) {
}
area := q.key.queue.AreaFor(focus)
q.pointer.queue.Deliver(area, pointer.Event{
Type: pointer.Scroll,
Kind: pointer.Scroll,
Source: pointer.Touch,
Scroll: f32internal.FPt(dist),
}, &q.handlers)
@@ -317,9 +317,9 @@ func (q *Router) ClickFocus() {
Source: pointer.Touch,
}
area := q.key.queue.AreaFor(focus)
e.Type = pointer.Press
e.Kind = pointer.Press
q.pointer.queue.Deliver(area, e, &q.handlers)
e.Type = pointer.Release
e.Kind = pointer.Release
q.pointer.queue.Deliver(area, e, &q.handlers)
}
@@ -438,7 +438,7 @@ func (q *Router) collect() {
op := pointer.InputOp{
Tag: encOp.Refs[0].(event.Tag),
Grab: encOp.Data[1] != 0,
Types: pointer.Type(bo.Uint16(encOp.Data[2:])),
Kinds: pointer.Kind(bo.Uint16(encOp.Data[2:])),
ScrollBounds: image.Rectangle{
Min: image.Point{
X: int(int32(bo.Uint32(encOp.Data[4:]))),
@@ -490,11 +490,11 @@ func (q *Router) collect() {
}
kc.softKeyboard(op.Show)
case ops.TypeKeyInput:
filter := encOp.Refs[1].(*key.Set)
filter := key.Set(*encOp.Refs[1].(*string))
op := key.InputOp{
Tag: encOp.Refs[0].(event.Tag),
Hint: key.InputHint(encOp.Data[1]),
Keys: *filter,
Keys: filter,
}
a := pc.currentArea()
b := pc.currentAreaBounds()
@@ -532,10 +532,10 @@ func (q *Router) collect() {
// Semantic ops.
case ops.TypeSemanticLabel:
lbl := encOp.Refs[0].(string)
lbl := *encOp.Refs[0].(*string)
pc.semanticLabel(lbl)
case ops.TypeSemanticDesc:
desc := encOp.Refs[0].(string)
desc := *encOp.Refs[0].(*string)
pc.semanticDesc(desc)
case ops.TypeSemanticClass:
class := semantic.ClassOp(encOp.Data[1])
@@ -546,11 +546,11 @@ func (q *Router) collect() {
} else {
pc.semanticSelected(false)
}
case ops.TypeSemanticDisabled:
case ops.TypeSemanticEnabled:
if encOp.Data[1] != 0 {
pc.semanticDisabled(true)
pc.semanticEnabled(true)
} else {
pc.semanticDisabled(false)
pc.semanticEnabled(false)
}
}
}
@@ -593,16 +593,6 @@ func (h *handlerEvents) HadEvents() bool {
func (h *handlerEvents) Events(k event.Tag) []event.Event {
if events, ok := h.handlers[k]; ok {
h.handlers[k] = h.handlers[k][:0]
// Schedule another frame if we delivered events to the user
// to flush half-updated state. This is important when an
// event changes UI state that has already been laid out. In
// the worst case, we waste a frame, increasing power usage.
//
// Gio is expected to grow the ability to construct
// frame-to-frame differences and only render to changed
// areas. In that case, the waste of a spurious frame should
// be minimal.
h.hadEvents = h.hadEvents || len(events) > 0
return events
}
return nil
+2 -2
View File
@@ -74,11 +74,11 @@ func TestSemanticTree(t *testing.T) {
func TestSemanticDescription(t *testing.T) {
var ops op.Ops
pointer.InputOp{Tag: new(int), Types: pointer.Press | pointer.Release}.Add(&ops)
pointer.InputOp{Tag: new(int), Kinds: pointer.Press | pointer.Release}.Add(&ops)
semantic.DescriptionOp("description").Add(&ops)
semantic.LabelOp("label").Add(&ops)
semantic.Button.Add(&ops)
semantic.DisabledOp(true).Add(&ops)
semantic.EnabledOp(false).Add(&ops)
semantic.SelectedOp(true).Add(&ops)
var r Router
r.Frame(&ops)
+8 -8
View File
@@ -36,16 +36,16 @@ const (
// boolean state.
type SelectedOp bool
// DisabledOp describes the disabled state.
type DisabledOp bool
// EnabledOp describes the enabled state.
type EnabledOp bool
func (l LabelOp) Add(o *op.Ops) {
data := ops.Write1(&o.Internal, ops.TypeSemanticLabelLen, string(l))
data := ops.Write1String(&o.Internal, ops.TypeSemanticLabelLen, string(l))
data[0] = byte(ops.TypeSemanticLabel)
}
func (d DescriptionOp) Add(o *op.Ops) {
data := ops.Write1(&o.Internal, ops.TypeSemanticDescLen, string(d))
data := ops.Write1String(&o.Internal, ops.TypeSemanticDescLen, string(d))
data[0] = byte(ops.TypeSemanticDesc)
}
@@ -63,10 +63,10 @@ func (s SelectedOp) Add(o *op.Ops) {
}
}
func (d DisabledOp) Add(o *op.Ops) {
data := ops.Write(&o.Internal, ops.TypeSemanticDisabledLen)
data[0] = byte(ops.TypeSemanticDisabled)
if d {
func (e EnabledOp) Add(o *op.Ops) {
data := ops.Write(&o.Internal, ops.TypeSemanticEnabledLen)
data[0] = byte(ops.TypeSemanticEnabled)
if e {
data[1] = 1
}
}
+24
View File
@@ -103,6 +103,30 @@ func ExampleStack() {
// Expand: {(50,50) (100,100)}
}
func ExampleBackground() {
gtx := layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Point{X: 100, Y: 100},
},
}
layout.Background{}.Layout(gtx,
// Force widget to the same size as the second.
func(gtx layout.Context) layout.Dimensions {
fmt.Printf("Expand: %v\n", gtx.Constraints)
return layoutWidget(gtx, 10, 10)
},
// Rigid 50x50 widget.
func(gtx layout.Context) layout.Dimensions {
return layoutWidget(gtx, 50, 50)
},
)
// Output:
// Expand: {(50,50) (100,100)}
}
func ExampleList() {
gtx := layout.Context{
Ops: new(op.Ops),
+1 -1
View File
@@ -144,7 +144,7 @@ func (l *List) Dragging() bool {
}
func (l *List) update(gtx Context) {
d := l.scroll.Scroll(gtx.Metric, gtx, gtx.Now, gesture.Axis(l.Axis))
d := l.scroll.Update(gtx.Metric, gtx, gtx.Now, gesture.Axis(l.Axis))
l.scrollDelta = d
l.Position.Offset += d
}
+9 -9
View File
@@ -93,18 +93,18 @@ func TestListPosition(t *testing.T) {
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(0, 0),
},
pointer.Event{
Source: pointer.Mouse,
Type: pointer.Scroll,
Kind: pointer.Scroll,
Scroll: f32.Pt(5, 0),
},
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(5, 0),
},
)},
@@ -113,18 +113,18 @@ func TestListPosition(t *testing.T) {
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(0, 0),
},
pointer.Event{
Source: pointer.Mouse,
Type: pointer.Scroll,
Kind: pointer.Scroll,
Scroll: f32.Pt(3, 0),
},
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(5, 0),
},
)},
@@ -133,18 +133,18 @@ func TestListPosition(t *testing.T) {
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(0, 0),
},
pointer.Event{
Source: pointer.Mouse,
Type: pointer.Scroll,
Kind: pointer.Scroll,
Scroll: f32.Pt(10, 0),
},
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(15, 0),
},
)},
+33
View File
@@ -118,3 +118,36 @@ func (s Stack) Layout(gtx Context, children ...StackChild) Dimensions {
Baseline: baseline,
}
}
// Background lays out single child widget on top of a background,
// centering, if necessary.
type Background struct{}
// Layout a widget and then add a background to it.
func (Background) Layout(gtx Context, background, widget Widget) Dimensions {
macro := op.Record(gtx.Ops)
wdims := widget(gtx)
baseline := wdims.Baseline
call := macro.Stop()
cgtx := gtx
cgtx.Constraints.Min = gtx.Constraints.Constrain(wdims.Size)
bdims := background(cgtx)
if bdims.Size != wdims.Size {
p := image.Point{
X: (bdims.Size.X - wdims.Size.X) / 2,
Y: (bdims.Size.Y - wdims.Size.Y) / 2,
}
baseline += (bdims.Size.Y - wdims.Size.Y) / 2
trans := op.Offset(p).Push(gtx.Ops)
defer trans.Pop()
}
call.Add(gtx.Ops)
return Dimensions{
Size: bdims.Size,
Baseline: baseline,
}
}
+66
View File
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: Unlicense OR MIT
package layout
import (
"image"
"testing"
"gioui.org/op"
)
func BenchmarkStack(b *testing.B) {
gtx := Context{
Ops: new(op.Ops),
Constraints: Constraints{
Max: image.Point{X: 100, Y: 100},
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
gtx.Ops.Reset()
Stack{}.Layout(gtx,
Expanded(emptyWidget{
Size: image.Point{X: 60, Y: 60},
}.Layout),
Stacked(emptyWidget{
Size: image.Point{X: 30, Y: 30},
}.Layout),
)
}
}
func BenchmarkBackground(b *testing.B) {
gtx := Context{
Ops: new(op.Ops),
Constraints: Constraints{
Max: image.Point{X: 100, Y: 100},
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
gtx.Ops.Reset()
Background{}.Layout(gtx,
emptyWidget{
Size: image.Point{X: 60, Y: 60},
}.Layout,
emptyWidget{
Size: image.Point{X: 30, Y: 30},
}.Layout,
)
}
}
type emptyWidget struct {
Size image.Point
}
func (w emptyWidget) Layout(gtx Context) Dimensions {
return Dimensions{Size: w.Size}
}
+7 -1
View File
@@ -29,7 +29,7 @@ type Op struct {
type Stack struct {
ops *ops.Ops
id ops.StackID
macroID int
macroID uint32
}
var pathSeed maphash.Seed
@@ -204,6 +204,9 @@ func (p *Path) Line(delta f32.Point) {
// LineTo moves the pen to the absolute point specified, recording a line.
func (p *Path) LineTo(to f32.Point) {
if to == p.pen {
return
}
data := ops.WriteMulti(p.ops, scene.CommandSize+4)
bo := binary.LittleEndian
bo.PutUint32(data[0:], uint32(p.contour))
@@ -250,6 +253,9 @@ func (p *Path) Quad(ctrl, to f32.Point) {
// QuadTo records a quadratic Bézier from the pen to end
// with the control point ctrl, with absolute coordinates.
func (p *Path) QuadTo(ctrl, to f32.Point) {
if ctrl == p.pen && to == p.pen {
return
}
data := ops.WriteMulti(p.ops, scene.CommandSize+4)
bo := binary.LittleEndian
bo.PutUint32(data[0:], uint32(p.contour))
+1 -1
View File
@@ -111,7 +111,7 @@ type TransformOp struct {
// TransformStack represents a TransformOp pushed on the transformation stack.
type TransformStack struct {
id ops.StackID
macroID int
macroID uint32
ops *ops.Ops
}
+49
View File
@@ -15,8 +15,20 @@ import (
"gioui.org/op/clip"
)
// ImageFilter is the scaling filter for images.
type ImageFilter byte
const (
// FilterLinear uses linear interpolation for scaling.
FilterLinear ImageFilter = iota
// FilterNearest uses nearest neighbor interpolation for scaling.
FilterNearest
)
// ImageOp sets the brush to an image.
type ImageOp struct {
Filter ImageFilter
uniform bool
color color.NRGBA
src *image.RGBA
@@ -44,6 +56,14 @@ type LinearGradientOp struct {
type PaintOp struct {
}
// OpacityStack represents an opacity applied to all painting operations
// until Pop is called.
type OpacityStack struct {
id ops.StackID
macroID uint32
ops *ops.Ops
}
// NewImageOp creates an ImageOp backed by src.
//
// NewImageOp assumes the backing image is immutable, and may cache a
@@ -95,6 +115,7 @@ func (i ImageOp) Add(o *op.Ops) {
}
data := ops.Write2(&o.Internal, ops.TypeImageLen, i.src, i.handle)
data[0] = byte(ops.TypeImage)
data[1] = byte(i.Filter)
}
func (c ColorOp) Add(o *op.Ops) {
@@ -145,3 +166,31 @@ func Fill(ops *op.Ops, c color.NRGBA) {
ColorOp{Color: c}.Add(ops)
PaintOp{}.Add(ops)
}
// PushOpacity creates a drawing layer with an opacity in the range [0;1].
// The layer includes every subsequent drawing operation until [OpacityStack.Pop]
// is called.
//
// The layer is drawn in two steps. First, the layer operations are
// drawn to a separate image. Then, the image is blended on top of
// the frame, with the opacity used as the blending factor.
func PushOpacity(o *op.Ops, opacity float32) OpacityStack {
if opacity > 1 {
opacity = 1
}
if opacity < 0 {
opacity = 0
}
id, macroID := ops.PushOp(&o.Internal, ops.OpacityStack)
data := ops.Write(&o.Internal, ops.TypePushOpacityLen)
bo := binary.LittleEndian
data[0] = byte(ops.TypePushOpacity)
bo.PutUint32(data[1:], math.Float32bits(opacity))
return OpacityStack{ops: &o.Internal, id: id, macroID: macroID}
}
func (t OpacityStack) Pop() {
ops.PopOp(t.ops, ops.OpacityStack, t.id, t.macroID)
data := ops.Write(t.ops, ops.TypePopOpacityLen)
data[0] = byte(ops.TypePopOpacity)
}
+246
View File
@@ -0,0 +1,246 @@
package text
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
type tokenKind uint8
const (
tokenStr tokenKind = iota
tokenComma
tokenEOF
)
type token struct {
kind tokenKind
value string
}
func (t token) String() string {
switch t.kind {
case tokenStr:
return t.value
case tokenComma:
return ","
case tokenEOF:
return "EOF"
default:
return "unknown"
}
}
type lexState func(*lexer) lexState
func lexText(l *lexer) lexState {
for {
switch r := l.next(); {
case r == -1:
l.ignore()
l.emit(tokenEOF)
return nil
case unicode.IsSpace(r):
continue
case r == ',':
l.ignore()
l.emit(tokenComma)
case r == '"':
l.ignore()
return lexDquote
case r == '\'':
l.ignore()
return lexSquote
default:
return lexBareStr
}
}
}
func lexBareStr(l *lexer) lexState {
defer l.emitProcessed(tokenStr, func(s string) (string, error) {
return strings.TrimSpace(s), nil
})
for {
if strings.HasPrefix(l.input[l.pos:], `,`) {
return lexText
}
switch r := l.next(); {
case r == -1:
return lexText
}
}
}
func lexDquote(l *lexer) lexState {
return lexQuote(l, `"`)
}
func lexSquote(l *lexer) lexState {
return lexQuote(l, `'`)
}
func unescape(s string, quote rune) (string, error) {
var b strings.Builder
hitNonSpace := false
var wb strings.Builder
for i := 0; i < len(s); {
r, sz := utf8.DecodeRuneInString(s[i:])
i += sz
if unicode.IsSpace(r) {
if !hitNonSpace {
continue
}
wb.WriteRune(r)
continue
}
hitNonSpace = true
// If we get here, we're not looking at whitespace.
// Insert any buffered up whitespace characters from
// the gap between words.
b.WriteString(wb.String())
wb.Reset()
if r == '\\' {
r, sz := utf8.DecodeRuneInString(s[i:])
i += sz
switch r {
case '\\', quote:
b.WriteRune(r)
default:
return "", fmt.Errorf("illegal escape sequence \\%c", r)
}
} else {
b.WriteRune(r)
}
}
return b.String(), nil
}
func lexQuote(l *lexer, mark string) lexState {
escaping := false
for {
if isQuote := strings.HasPrefix(l.input[l.pos:], mark); isQuote && !escaping {
err := l.emitProcessed(tokenStr, func(s string) (string, error) {
return unescape(s, []rune(mark)[0])
})
if err != nil {
l.err = err
return nil
}
l.next()
l.ignore()
return lexText
}
escaped := escaping
switch r := l.next(); {
case r == -1:
l.err = fmt.Errorf("unexpected EOF while parsing %s-quoted family", mark)
return lexText
case r == '\\':
if !escaped {
escaping = true
}
}
if escaped {
escaping = false
}
}
}
type lexer struct {
input string
pos int
tokens []token
err error
}
func (l *lexer) ignore() {
l.input = l.input[l.pos:]
l.pos = 0
}
// next decodes the next rune in the input and returns it.
func (l *lexer) next() int32 {
if l.pos >= len(l.input) {
return -1
}
r, w := utf8.DecodeRuneInString(l.input[l.pos:])
l.pos += w
return r
}
// emit adds a token of the given kind.
func (l *lexer) emit(t tokenKind) {
l.emitProcessed(t, func(s string) (string, error) { return s, nil })
}
// emitProcessed adds a token of the given kind, but transforms its value
// with the provided closure first.
func (l *lexer) emitProcessed(t tokenKind, f func(string) (string, error)) error {
val, err := f(l.input[:l.pos])
l.tokens = append(l.tokens, token{
kind: t,
value: val,
})
l.ignore()
return err
}
// run executes the lexer on the given input.
func (l *lexer) run(input string) ([]token, error) {
l.input = input
l.tokens = l.tokens[:0]
l.pos = 0
for state := lexText; state != nil; {
state = state(l)
}
return l.tokens, l.err
}
// parser implements a simple recursive descent parser for font family fallback
// expressions.
type parser struct {
faces []string
lexer lexer
tokens []token
}
// parse the provided rule and return the extracted font families. The returned families
// are valid only until the next call to parse. If parsing fails, an error describing the
// failure is returned instead.
func (p *parser) parse(rule string) ([]string, error) {
var err error
p.tokens, err = p.lexer.run(rule)
if err != nil {
return nil, err
}
p.faces = p.faces[:0]
return p.faces, p.parseList()
}
// parse implements the production:
//
// LIST ::= <FACE> <COMMA> <LIST> | <FACE>
func (p *parser) parseList() error {
if len(p.tokens) < 0 {
return fmt.Errorf("expected family name, got EOF")
}
if head := p.tokens[0]; head.kind != tokenStr {
return fmt.Errorf("expected family name, got %s", head)
} else {
p.faces = append(p.faces, head.value)
p.tokens = p.tokens[1:]
}
switch head := p.tokens[0]; head.kind {
case tokenEOF:
return nil
case tokenComma:
p.tokens = p.tokens[1:]
return p.parseList()
default:
return fmt.Errorf("unexpected token %s", head)
}
}
+179
View File
@@ -0,0 +1,179 @@
package text
import (
"testing"
"golang.org/x/exp/slices"
)
func TestParser(t *testing.T) {
type scenario struct {
variantName string
input string
}
type testcase struct {
name string
inputs []scenario
expected []string
shouldErr bool
}
for _, tc := range []testcase{
{
name: "empty",
inputs: []scenario{
{
variantName: "",
},
},
shouldErr: true,
},
{
name: "comma failure",
inputs: []scenario{
{
variantName: "bare single",
input: ",",
},
{
variantName: "bare multiple",
input: ",, ,,",
},
},
shouldErr: true,
},
{
name: "comma success",
inputs: []scenario{
{
variantName: "squote",
input: "','",
},
{
variantName: "dquote",
input: `","`,
},
},
expected: []string{","},
},
{
name: "comma success multiple",
inputs: []scenario{
{
variantName: "squote",
input: "',,', ',,'",
},
{
variantName: "dquote",
input: `",,", ",,"`,
},
},
expected: []string{",,", ",,"},
},
{
name: "backslashes",
inputs: []scenario{
{
variantName: "bare",
input: `\font\\`,
},
{
variantName: "dquote",
input: `"\\font\\\\"`,
},
{
variantName: "squote",
input: `'\\font\\\\'`,
},
},
expected: []string{`\font\\`},
},
{
name: "invalid backslashes",
inputs: []scenario{
{
variantName: "dquote",
input: `"\\""`,
},
{
variantName: "squote",
input: `'\\''`,
},
},
shouldErr: true,
},
{
name: "too many quotes",
inputs: []scenario{
{
variantName: "dquote",
input: `"""`,
},
{
variantName: "squote",
input: `'''`,
},
},
shouldErr: true,
},
{
name: "serif serif's serif\"s",
inputs: []scenario{
{
variantName: "bare",
input: `serif, serif's, serif"s`,
},
{
variantName: "squote",
input: `'serif', 'serif\'s', 'serif"s'`,
},
{
variantName: "dquote",
input: `"serif", "serif's", "serif\"s"`,
},
},
expected: []string{"serif", `serif's`, `serif"s`},
},
{
name: "complex list",
inputs: []scenario{
{
variantName: "bare",
input: `Times New Roman, Georgia Common, Helvetica Neue, serif`,
},
{
variantName: "squote",
input: `'Times New Roman', 'Georgia Common', 'Helvetica Neue', 'serif'`,
},
{
variantName: "dquote",
input: `"Times New Roman", "Georgia Common", "Helvetica Neue", "serif"`,
},
{
variantName: "mixed",
input: `Times New Roman, "Georgia Common", 'Helvetica Neue', "serif"`,
},
{
variantName: "mixed with weird spacing",
input: `Times New Roman ,"Georgia Common" , 'Helvetica Neue' ,"serif"`,
},
},
expected: []string{"Times New Roman", "Georgia Common", "Helvetica Neue", "serif"},
},
} {
t.Run(tc.name, func(t *testing.T) {
var p parser
for _, scen := range tc.inputs {
t.Run(scen.variantName, func(t *testing.T) {
actual, err := p.parse(scen.input)
if (err != nil) != tc.shouldErr {
t.Errorf("unexpected error state: %v", err)
}
if !slices.Equal(tc.expected, actual) {
t.Errorf("expected\n%q\ngot\n%q", tc.expected, actual)
}
})
}
})
}
}
+282 -242
View File
@@ -6,12 +6,15 @@ import (
"bytes"
"image"
"io"
"sort"
"log"
"os"
"github.com/go-text/typesetting/di"
"github.com/go-text/typesetting/font"
"github.com/go-text/typesetting/fontscan"
"github.com/go-text/typesetting/language"
"github.com/go-text/typesetting/opentype/api"
"github.com/go-text/typesetting/opentype/api/metadata"
"github.com/go-text/typesetting/shaping"
"golang.org/x/exp/slices"
"golang.org/x/image/math/fixed"
@@ -19,6 +22,8 @@ import (
"gioui.org/f32"
giofont "gioui.org/font"
"gioui.org/font/opentype"
"gioui.org/internal/debug"
"gioui.org/io/system"
"gioui.org/op"
"gioui.org/op/clip"
@@ -31,7 +36,8 @@ type document struct {
lines []line
alignment Alignment
// alignWidth is the width used when aligning text.
alignWidth int
alignWidth int
unreadRuneCount int
}
// append adds the lines of other to the end of l and ensures they
@@ -47,6 +53,7 @@ func (l *document) reset() {
l.lines = l.lines[:0]
l.alignment = Start
l.alignWidth = 0
l.unreadRuneCount = 0
}
func max(a, b int) int {
@@ -74,8 +81,9 @@ type line struct {
// descent is the height below the baseline, including
// the line gap.
descent fixed.Int26_6
// bounds is the visible bounds of the line.
bounds fixed.Rectangle26_6
// lineHeight captures the gap that should exist between the baseline of this
// line and the previous (if any).
lineHeight fixed.Int26_6
// direction is the dominant direction of the line. This direction will be
// used to align the text content of the line, but may not match the actual
// direction of the runs of text within the line (such as an RTL sentence
@@ -87,6 +95,55 @@ type line struct {
yOffset int
}
// insertTrailingSyntheticNewline adds a synthetic newline to the final logical run of the line
// with the given shaping cluster index.
func (l *line) insertTrailingSyntheticNewline(newLineClusterIdx int) {
// If there was a newline at the end of this paragraph, insert a synthetic glyph representing it.
finalContentRun := len(l.runs) - 1
// If there was a trailing newline update the rune counts to include
// it on the last line of the paragraph.
l.runeCount += 1
l.runs[finalContentRun].Runes.Count += 1
syntheticGlyph := glyph{
id: 0,
clusterIndex: newLineClusterIdx,
glyphCount: 0,
runeCount: 1,
xAdvance: 0,
yAdvance: 0,
xOffset: 0,
yOffset: 0,
}
// Inset the synthetic newline glyph on the proper end of the run.
if l.runs[finalContentRun].Direction.Progression() == system.FromOrigin {
l.runs[finalContentRun].Glyphs = append(l.runs[finalContentRun].Glyphs, syntheticGlyph)
} else {
// Ensure capacity.
l.runs[finalContentRun].Glyphs = append(l.runs[finalContentRun].Glyphs, glyph{})
copy(l.runs[finalContentRun].Glyphs[1:], l.runs[finalContentRun].Glyphs)
l.runs[finalContentRun].Glyphs[0] = syntheticGlyph
}
}
func (l *line) setTruncatedCount(truncatedCount int) {
// If we've truncated the text with a truncator, adjust the rune counts within the
// truncator to make it represent the truncated text.
finalRunIdx := len(l.runs) - 1
l.runs[finalRunIdx].truncator = true
finalGlyphIdx := len(l.runs[finalRunIdx].Glyphs) - 1
// The run represents all of the truncated text.
l.runs[finalRunIdx].Runes.Count = truncatedCount
// Only the final glyph represents any runes, and it represents all truncated text.
for i := range l.runs[finalRunIdx].Glyphs {
if i == finalGlyphIdx {
l.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = truncatedCount
} else {
l.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = 0
}
}
}
// Range describes the position and quantity of a range of text elements
// within a larger slice. The unit is usually runes of unicode data or
// glyphs of shaped font data.
@@ -149,112 +206,18 @@ type runLayout struct {
truncator bool
}
// faceOrderer chooses the order in which faces should be applied to text.
type faceOrderer struct {
def giofont.Font
faceScratch []font.Face
fontDefaultOrder map[giofont.Font]int
defaultOrderedFonts []giofont.Font
faces map[giofont.Font]font.Face
faceToIndex map[font.Face]int
fonts []giofont.Font
}
func (f *faceOrderer) insert(fnt giofont.Font, face font.Face) {
if len(f.fonts) == 0 {
f.def = fnt
}
if f.fontDefaultOrder == nil {
f.fontDefaultOrder = make(map[giofont.Font]int)
}
if f.faces == nil {
f.faces = make(map[giofont.Font]font.Face)
f.faceToIndex = make(map[font.Face]int)
}
f.fontDefaultOrder[fnt] = len(f.faceScratch)
f.defaultOrderedFonts = append(f.defaultOrderedFonts, fnt)
f.faceScratch = append(f.faceScratch, face)
f.fonts = append(f.fonts, fnt)
f.faces[fnt] = face
f.faceToIndex[face] = f.fontDefaultOrder[fnt]
}
// resetFontOrder restores the fonts to a predictable order. It should be invoked
// before any operation searching the fonts.
func (c *faceOrderer) resetFontOrder() {
copy(c.fonts, c.defaultOrderedFonts)
}
func (c *faceOrderer) indexFor(face font.Face) int {
return c.faceToIndex[face]
}
func (c *faceOrderer) faceFor(idx int) font.Face {
if idx < len(c.defaultOrderedFonts) {
return c.faces[c.defaultOrderedFonts[idx]]
}
panic("face index not found")
}
// TODO(whereswaldon): this function could sort all faces by appropriateness for the
// given font characteristics. This would ensure that (if possible) text using a
// fallback font would select similar weights and emphases to the primary font.
func (c *faceOrderer) sortedFacesForStyle(font giofont.Font) []font.Face {
c.resetFontOrder()
primary, ok := c.fontForStyle(font)
if !ok {
font.Typeface = c.def.Typeface
primary, ok = c.fontForStyle(font)
if !ok {
primary = c.def
}
}
return c.sorted(primary)
}
// fontForStyle returns the closest existing font to the requested font within the
// same typeface.
func (c *faceOrderer) fontForStyle(font giofont.Font) (giofont.Font, bool) {
if closest, ok := closestFont(font, c.fonts); ok {
return closest, true
}
font.Style = giofont.Regular
if closest, ok := closestFont(font, c.fonts); ok {
return closest, true
}
return font, false
}
// faces returns a slice of faces with primary as the first element and
// the remaining faces ordered by insertion order.
func (f *faceOrderer) sorted(primary giofont.Font) []font.Face {
// If we find primary, put it first, and omit it from the below sort.
lowest := 0
for i := range f.fonts {
if f.fonts[i] == primary {
if i != 0 {
f.fonts[0], f.fonts[i] = f.fonts[i], f.fonts[0]
}
lowest = 1
break
}
}
sorting := f.fonts[lowest:]
sort.Slice(sorting, func(i, j int) bool {
a := sorting[i]
b := sorting[j]
return f.fontDefaultOrder[a] < f.fontDefaultOrder[b]
})
for i, font := range f.fonts {
f.faceScratch[i] = f.faces[font]
}
return f.faceScratch
}
// shaperImpl implements the shaping and line-wrapping of opentype fonts.
type shaperImpl struct {
// Fields for tracking fonts/faces.
orderer faceOrderer
fontMap *fontscan.FontMap
faces []font.Face
faceToIndex map[font.Font]int
faceMeta []giofont.Font
defaultFaces []string
logger interface {
Printf(format string, args ...any)
}
parser parser
// Shaping and wrapping state.
shaper shaping.HarfbuzzShaper
@@ -271,11 +234,61 @@ type shaperImpl struct {
bitmapGlyphCache bitmapCache
}
// debugLogger only logs messages if debug.Text is true.
type debugLogger struct {
*log.Logger
}
func newDebugLogger() debugLogger {
return debugLogger{Logger: log.New(log.Writer(), "[text] ", log.Default().Flags())}
}
func (d debugLogger) Printf(format string, args ...any) {
if debug.Text.Load() {
d.Logger.Printf(format, args...)
}
}
func newShaperImpl(systemFonts bool, collection []FontFace) *shaperImpl {
var shaper shaperImpl
shaper.logger = newDebugLogger()
shaper.fontMap = fontscan.NewFontMap(shaper.logger)
shaper.faceToIndex = make(map[font.Font]int)
if systemFonts {
str, err := os.UserCacheDir()
if err != nil {
shaper.logger.Printf("failed resolving font cache dir: %v", err)
shaper.logger.Printf("skipping system font load")
}
if err := shaper.fontMap.UseSystemFonts(str); err != nil {
shaper.logger.Printf("failed loading system fonts: %v", err)
}
}
for _, f := range collection {
shaper.Load(f)
shaper.defaultFaces = append(shaper.defaultFaces, string(f.Font.Typeface))
}
shaper.shaper.SetFontCacheSize(32)
return &shaper
}
// Load registers the provided FontFace with the shaper, if it is compatible.
// It returns whether the face is now available for use. FontFaces are prioritized
// in the order in which they are loaded, with the first face being the default.
func (s *shaperImpl) Load(f FontFace) {
s.orderer.insert(f.Font, f.Face.Face())
s.fontMap.AddFace(f.Face.Face(), opentype.FontToDescription(f.Font))
s.addFace(f.Face.Face(), f.Font)
}
func (s *shaperImpl) addFace(f font.Face, md giofont.Font) {
if _, ok := s.faceToIndex[f.Font]; ok {
return
}
s.logger.Printf("loaded face %s(style:%s, weight:%d)", md.Typeface, md.Style, md.Weight)
idx := len(s.faces)
s.faceToIndex[f.Font] = idx
s.faces = append(s.faces, f)
s.faceMeta = append(s.faceMeta, md)
}
// splitByScript divides the inputs into new, smaller inputs on script boundaries
@@ -359,9 +372,26 @@ func (s *shaperImpl) splitBidi(input shaping.Input) []shaping.Input {
return splitInputs
}
// ResolveFace allows shaperImpl to implement shaping.FontMap, wrapping its fontMap
// field and ensuring that any faces loaded as part of the search are registered with
// ids so that they can be referred to by a GlyphID.
func (s *shaperImpl) ResolveFace(r rune) font.Face {
face := s.fontMap.ResolveFace(r)
if face != nil {
family, aspect := s.fontMap.FontMetadata(face.Font)
md := opentype.DescriptionToFont(metadata.Description{
Family: family,
Aspect: aspect,
})
s.addFace(face, md)
return face
}
return nil
}
// splitByFaces divides the inputs by font coverage in the provided faces. It will use the slice provided in buf
// as the backing storage of the returned slice if buf is non-nil.
func (s *shaperImpl) splitByFaces(inputs []shaping.Input, faces []font.Face, buf []shaping.Input) []shaping.Input {
func (s *shaperImpl) splitByFaces(inputs []shaping.Input, buf []shaping.Input) []shaping.Input {
var split []shaping.Input
if buf == nil {
split = make([]shaping.Input, 0, len(inputs))
@@ -369,34 +399,78 @@ func (s *shaperImpl) splitByFaces(inputs []shaping.Input, faces []font.Face, buf
split = buf
}
for _, input := range inputs {
split = append(split, shaping.SplitByFontGlyphs(input, faces)...)
split = append(split, shaping.SplitByFace(input, s)...)
}
return split
}
// shapeText invokes the text shaper and returns the raw text data in the shaper's native
// format. It does not wrap lines.
func (s *shaperImpl) shapeText(faces []font.Face, ppem fixed.Int26_6, lc system.Locale, txt []rune) []shaping.Output {
if len(faces) < 1 {
return nil
}
func (s *shaperImpl) shapeText(ppem fixed.Int26_6, lc system.Locale, txt []rune) []shaping.Output {
lcfg := langConfig{
Language: language.NewLanguage(lc.Language),
Direction: mapDirection(lc.Direction),
}
// Create an initial input.
input := toInput(faces[0], ppem, lcfg, txt)
input := toInput(nil, ppem, lcfg, txt)
if input.RunStart == input.RunEnd && len(s.faces) > 0 {
// Give the empty string a face. This is a necessary special case because
// the face splitting process works by resolving faces for each rune, and
// the empty string contains no runes.
input.Face = s.faces[0]
}
// Break input on font glyph coverage.
inputs := s.splitBidi(input)
inputs = s.splitByFaces(inputs, faces, s.splitScratch1[:0])
inputs = s.splitByFaces(inputs, s.splitScratch1[:0])
inputs = splitByScript(inputs, lcfg.Direction, s.splitScratch2[:0])
// Shape all inputs.
if needed := len(inputs) - len(s.outScratchBuf); needed > 0 {
s.outScratchBuf = slices.Grow(s.outScratchBuf, needed)
}
s.outScratchBuf = s.outScratchBuf[:len(inputs)]
for i := range inputs {
s.outScratchBuf[i] = s.shaper.Shape(inputs[i])
s.outScratchBuf = s.outScratchBuf[:0]
for _, input := range inputs {
if input.Face != nil {
s.outScratchBuf = append(s.outScratchBuf, s.shaper.Shape(input))
} else {
s.outScratchBuf = append(s.outScratchBuf, shaping.Output{
// Use the text size as the advance of the entire fake run so that
// it doesn't occupy zero space.
Advance: input.Size,
Size: input.Size,
Glyphs: []shaping.Glyph{
{
Width: input.Size,
Height: input.Size,
XBearing: 0,
YBearing: 0,
XAdvance: input.Size,
YAdvance: input.Size,
XOffset: 0,
YOffset: 0,
ClusterIndex: input.RunStart,
RuneCount: input.RunEnd - input.RunStart,
GlyphCount: 1,
GlyphID: 0,
Mask: 0,
},
},
LineBounds: shaping.Bounds{
Ascent: input.Size,
Descent: 0,
Gap: 0,
},
GlyphBounds: shaping.Bounds{
Ascent: input.Size,
Descent: 0,
Gap: 0,
},
Direction: input.Direction,
Runes: shaping.Range{
Offset: input.RunStart,
Count: input.RunEnd - input.RunStart,
},
})
}
}
return s.outScratchBuf
}
@@ -413,22 +487,35 @@ func wrapPolicyToGoText(p WrapPolicy) shaping.LineBreakPolicy {
}
// shapeAndWrapText invokes the text shaper and returns wrapped lines in the shaper's native format.
func (s *shaperImpl) shapeAndWrapText(faces []font.Face, params Parameters, txt []rune) (_ []shaping.Line, truncated int) {
func (s *shaperImpl) shapeAndWrapText(params Parameters, txt []rune) (_ []shaping.Line, truncated int) {
wc := shaping.WrapConfig{
TruncateAfterLines: params.MaxLines,
TextContinues: params.forceTruncate,
BreakPolicy: wrapPolicyToGoText(params.WrapPolicy),
}
families := s.defaultFaces
if params.Font.Typeface != "" {
parsed, err := s.parser.parse(string(params.Font.Typeface))
if err != nil {
s.logger.Printf("Unable to parse typeface %q: %v", params.Font.Typeface, err)
} else {
families = parsed
}
}
s.fontMap.SetQuery(fontscan.Query{
Families: families,
Aspect: opentype.FontToDescription(params.Font).Aspect,
})
if wc.TruncateAfterLines > 0 {
if len(params.Truncator) == 0 {
params.Truncator = "…"
}
// We only permit a single run as the truncator, regardless of whether more were generated.
// Just use the first one.
wc.Truncator = s.shapeText(faces, params.PxPerEm, params.Locale, []rune(params.Truncator))[0]
wc.Truncator = s.shapeText(params.PxPerEm, params.Locale, []rune(params.Truncator))[0]
}
// Wrap outputs into lines.
return s.wrapper.WrapParagraph(wc, params.MaxWidth, txt, shaping.NewSliceIterator(s.shapeText(faces, params.PxPerEm, params.Locale, txt)))
return s.wrapper.WrapParagraph(wc, params.MaxWidth, txt, shaping.NewSliceIterator(s.shapeText(params.PxPerEm, params.Locale, txt)))
}
// replaceControlCharacters replaces problematic unicode
@@ -471,83 +558,72 @@ func (s *shaperImpl) Layout(params Parameters, txt io.RuneReader) document {
}
func calculateYOffsets(lines []line) {
currentY := 0
prevDesc := fixed.I(0)
if len(lines) < 1 {
return
}
// Ceil the first value to ensure that we don't baseline it too close to the top of the
// viewport and cut off the top pixel.
currentY := lines[0].ascent.Ceil()
for i := range lines {
ascent, descent := lines[i].ascent, lines[i].descent
currentY += (prevDesc + ascent).Ceil()
if i > 0 {
currentY += lines[i].lineHeight.Round()
}
lines[i].yOffset = currentY
prevDesc = descent
}
}
// LayoutRunes shapes and wraps the text, and returns the result in Gio's shaped text format.
func (s *shaperImpl) LayoutRunes(params Parameters, txt []rune) document {
hasNewline := len(txt) > 0 && txt[len(txt)-1] == '\n'
var ls []shaping.Line
var truncated int
if hasNewline {
txt = txt[:len(txt)-1]
}
ls, truncated := s.shapeAndWrapText(s.orderer.sortedFacesForStyle(params.Font), params, replaceControlCharacters(txt))
if params.MaxLines != 0 && hasNewline {
// If we might end up truncating a trailing newline, we must insert the truncator symbol
// on the final line (if we hit the limit).
params.forceTruncate = true
}
ls, truncated = s.shapeAndWrapText(params, replaceControlCharacters(txt))
didTruncate := truncated > 0 || (params.forceTruncate && params.MaxLines == len(ls))
if didTruncate && hasNewline {
// We've truncated the newline, since it was at the end and we've truncated some amount of runes
// before it.
hasTruncator := truncated > 0 || (params.forceTruncate && params.MaxLines == len(ls))
if hasTruncator && hasNewline {
// We have a truncator at the end of the line, so the newline is logically
// truncated as well.
truncated++
hasNewline = false
}
// Convert to Lines.
textLines := make([]line, len(ls))
maxHeight := fixed.Int26_6(0)
for i := range ls {
otLine := toLine(&s.orderer, ls[i], params.Locale.Direction)
isFinalLine := i == len(ls)-1
if isFinalLine && hasNewline {
// If there was a trailing newline update the rune counts to include
// it on the last line of the paragraph.
finalRunIdx := len(otLine.runs) - 1
otLine.runeCount += 1
otLine.runs[finalRunIdx].Runes.Count += 1
syntheticGlyph := glyph{
id: 0,
clusterIndex: len(txt),
glyphCount: 0,
runeCount: 1,
xAdvance: 0,
yAdvance: 0,
xOffset: 0,
yOffset: 0,
}
// Inset the synthetic newline glyph on the proper end of the run.
if otLine.runs[finalRunIdx].Direction.Progression() == system.FromOrigin {
otLine.runs[finalRunIdx].Glyphs = append(otLine.runs[finalRunIdx].Glyphs, syntheticGlyph)
} else {
// Ensure capacity.
otLine.runs[finalRunIdx].Glyphs = append(otLine.runs[finalRunIdx].Glyphs, glyph{})
copy(otLine.runs[finalRunIdx].Glyphs[1:], otLine.runs[finalRunIdx].Glyphs)
otLine.runs[finalRunIdx].Glyphs[0] = syntheticGlyph
}
otLine := toLine(s.faceToIndex, ls[i], params.Locale.Direction)
if otLine.lineHeight > maxHeight {
maxHeight = otLine.lineHeight
}
if isFinalLine && didTruncate {
// If we've truncated the text with a truncator, adjust the rune counts within the
// truncator to make it represent the truncated text.
finalRunIdx := len(otLine.runs) - 1
otLine.runs[finalRunIdx].truncator = true
finalGlyphIdx := len(otLine.runs[finalRunIdx].Glyphs) - 1
// The run represents all of the truncated text.
otLine.runs[finalRunIdx].Runes.Count = truncated
// Only the final glyph represents any runes, and it represents all truncated text.
for i := range otLine.runs[finalRunIdx].Glyphs {
if i == finalGlyphIdx {
otLine.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = truncated
} else {
otLine.runs[finalRunIdx].Glyphs[finalGlyphIdx].runeCount = 0
}
if isFinalLine := i == len(ls)-1; isFinalLine {
if hasNewline {
otLine.insertTrailingSyntheticNewline(len(txt))
}
if hasTruncator {
otLine.setTruncatedCount(truncated)
}
}
textLines[i] = otLine
}
if params.LineHeight != 0 {
maxHeight = params.LineHeight
}
if params.LineHeightScale == 0 {
params.LineHeightScale = 1.2
}
maxHeight = floatToFixed(fixedToFloat(maxHeight) * params.LineHeightScale)
for i := range textLines {
textLines[i].lineHeight = maxHeight
}
calculateYOffsets(textLines)
return document{
lines: textLines,
@@ -575,7 +651,13 @@ func (s *shaperImpl) Shape(pathOps *op.Ops, gs []Glyph) clip.PathSpec {
x = g.X
}
ppem, faceIdx, gid := splitGlyphID(g.ID)
face := s.orderer.faceFor(faceIdx)
if faceIdx >= len(s.faces) {
continue
}
face := s.faces[faceIdx]
if face == nil {
continue
}
scaleFactor := fixedToFloat(ppem) / float32(face.Upem())
glyphData := face.GlyphData(gid)
switch glyphData := glyphData.(type) {
@@ -633,6 +715,10 @@ func fixedToFloat(i fixed.Int26_6) float32 {
return float32(i) / 64.0
}
func floatToFixed(f float32) fixed.Int26_6 {
return fixed.Int26_6(f * 64)
}
// Bitmaps returns an op.CallOp that will display all bitmap glyphs within gs.
// The positioning of the bitmaps uses the same logic as Shape(), so the returned
// CallOp can be added at the same offset as the path data returned by Shape()
@@ -645,7 +731,13 @@ func (s *shaperImpl) Bitmaps(ops *op.Ops, gs []Glyph) op.CallOp {
x = g.X
}
_, faceIdx, gid := splitGlyphID(g.ID)
face := s.orderer.faceFor(faceIdx)
if faceIdx >= len(s.faces) {
continue
}
face := s.faces[faceIdx]
if face == nil {
continue
}
glyphData := face.GlyphData(gid)
switch glyphData := glyphData.(type) {
case api.GlyphBitmap:
@@ -674,7 +766,7 @@ func (s *shaperImpl) Bitmaps(ops *op.Ops, gs []Glyph) op.CallOp {
}
off := op.Affine(f32.Affine2D{}.Offset(f32.Point{
X: fixedToFloat((g.X - x) - g.Offset.X),
Y: fixedToFloat(g.Offset.Y - g.Ascent),
Y: fixedToFloat(g.Offset.Y + g.Bounds.Min.Y),
})).Push(ops)
cl := clip.Rect{Max: imgSize}.Push(ops)
@@ -773,7 +865,7 @@ func toGioGlyphs(in []shaping.Glyph, ppem fixed.Int26_6, faceIdx int) []glyph {
}
// toLine converts the output into a Line with the provided dominant text direction.
func toLine(orderer *faceOrderer, o shaping.Line, dir system.TextDirection) line {
func toLine(faceToIndex map[font.Font]int, o shaping.Line, dir system.TextDirection) line {
if len(o) < 1 {
return line{}
}
@@ -781,10 +873,18 @@ func toLine(orderer *faceOrderer, o shaping.Line, dir system.TextDirection) line
runs: make([]runLayout, len(o)),
direction: dir,
}
maxSize := fixed.Int26_6(0)
for i := range o {
run := o[i]
if run.Size > maxSize {
maxSize = run.Size
}
var font font.Font
if run.Face != nil {
font = run.Face.Font
}
line.runs[i] = runLayout{
Glyphs: toGioGlyphs(run.Glyphs, run.Size, orderer.indexFor(run.Face)),
Glyphs: toGioGlyphs(run.Glyphs, run.Size, faceToIndex[font]),
Runes: Range{
Count: run.Runes.Count,
Offset: line.runeCount,
@@ -795,13 +895,6 @@ func toLine(orderer *faceOrderer, o shaping.Line, dir system.TextDirection) line
PPEM: run.Size,
}
line.runeCount += run.Runes.Count
if line.bounds.Min.Y > -run.LineBounds.Ascent {
line.bounds.Min.Y = -run.LineBounds.Ascent
}
if line.bounds.Max.Y < -run.LineBounds.Ascent+run.LineBounds.LineHeight() {
line.bounds.Max.Y = -run.LineBounds.Ascent + run.LineBounds.LineHeight()
}
line.bounds.Max.X += run.Advance
line.width += run.Advance
if line.ascent < run.LineBounds.Ascent {
line.ascent = run.LineBounds.Ascent
@@ -810,21 +903,8 @@ func toLine(orderer *faceOrderer, o shaping.Line, dir system.TextDirection) line
line.descent = -run.LineBounds.Descent + run.LineBounds.Gap
}
}
line.lineHeight = maxSize
computeVisualOrder(&line)
// Account for glyphs hanging off of either side in the bounds.
if len(line.visualOrder) > 0 {
runIdx := line.visualOrder[0]
run := o[runIdx]
if len(run.Glyphs) > 0 {
line.bounds.Min.X = run.Glyphs[0].LeftSideBearing()
}
runIdx = line.visualOrder[len(line.visualOrder)-1]
run = o[runIdx]
if len(run.Glyphs) > 0 {
lastGlyphIdx := len(run.Glyphs) - 1
line.bounds.Max.X += run.Glyphs[lastGlyphIdx].RightSideBearing()
}
}
return line
}
@@ -885,43 +965,3 @@ func computeVisualOrder(l *line) {
x += l.runs[runIdx].Advance
}
}
// closestFont returns the closest Font in available by weight.
// In case of equality the lighter weight will be returned.
func closestFont(lookup giofont.Font, available []giofont.Font) (giofont.Font, bool) {
found := false
var match giofont.Font
for _, cf := range available {
if cf == lookup {
return lookup, true
}
if cf.Typeface != lookup.Typeface || cf.Variant != lookup.Variant || cf.Style != lookup.Style {
continue
}
if !found {
found = true
match = cf
continue
}
cDist := weightDistance(lookup.Weight, cf.Weight)
mDist := weightDistance(lookup.Weight, match.Weight)
if cDist < mDist {
match = cf
} else if cDist == mDist && cf.Weight < match.Weight {
match = cf
}
}
return match, found
}
// weightDistance returns the distance value between two font weights.
func weightDistance(wa giofont.Weight, wb giofont.Weight) int {
// Avoid dealing with negative Weight values.
a := int(wa) + 400
b := int(wb) + 400
diff := a - b
if diff < 0 {
return -diff
}
return diff
}
+30 -129
View File
@@ -30,11 +30,12 @@ var arabic = system.Locale{
}
func testShaper(faces ...giofont.Face) *shaperImpl {
shaper := shaperImpl{}
ff := make([]FontFace, 0, len(faces))
for _, face := range faces {
shaper.Load(FontFace{Face: face})
ff = append(ff, FontFace{Face: face})
}
return &shaper
shaper := newShaperImpl(false, ff)
return shaper
}
func TestEmptyString(t *testing.T) {
@@ -51,19 +52,26 @@ func TestEmptyString(t *testing.T) {
t.Fatalf("Layout returned no lines for empty string; expected 1")
}
l := lines.lines[0]
exp := fixed.Rectangle26_6{
Min: fixed.Point26_6{
Y: fixed.Int26_6(-12094),
},
Max: fixed.Point26_6{
Y: fixed.Int26_6(2700),
},
if expected := fixed.Int26_6(12094); l.ascent != expected {
t.Errorf("unexpected ascent for empty string: %v, expected %v", l.ascent, expected)
}
if got := l.bounds; got != exp {
t.Errorf("got bounds %+v for empty string; expected %+v", got, exp)
if expected := fixed.Int26_6(2700); l.descent != expected {
t.Errorf("unexpected descent for empty string: %v, expected %v", l.descent, expected)
}
}
func TestNoFaces(t *testing.T) {
ppem := fixed.I(200)
shaper := testShaper()
// Ensure shaping text with no faces does not panic.
shaper.LayoutRunes(Parameters{
PxPerEm: ppem,
MaxWidth: 2000,
Locale: english,
}, []rune("✨ⷽℎ↞⋇ⱜ⪫⢡⽛⣦␆Ⱨⳏ⳯⒛⭣╎⌞⟻⢇┃➡⬎⩱⸇ⷎ⟅▤⼶⇺⩳⎏⤬⬞ⴈ⋠⿶⢒₍☟⽂ⶦ⫰⭢⌹∼▀⾯⧂❽⩏ⓖ⟅⤔⍇␋⽓ₑ⢳⠑❂⊪⢘⽨⃯▴ⷿ"))
}
func TestAlignWidth(t *testing.T) {
lines := []line{
{width: fixed.I(50)},
@@ -288,13 +296,13 @@ func makeTestText(shaper *shaperImpl, primaryDir system.TextDirection, fontSize,
rtlSource = string(complexRunes[:runeLimit])
}
}
simpleText, _ := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(giofont.Font{}), Parameters{
simpleText, _ := shaper.shapeAndWrapText(Parameters{
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
Locale: locale,
}, []rune(simpleSource))
simpleText = copyLines(simpleText)
complexText, _ := shaper.shapeAndWrapText(shaper.orderer.sortedFacesForStyle(giofont.Font{}), Parameters{
complexText, _ := shaper.shapeAndWrapText(Parameters{
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
Locale: locale,
@@ -378,13 +386,7 @@ func TestToLine(t *testing.T) {
totalInputGlyphs += len(run.Glyphs)
totalInputRunes += run.Runes.Count
}
output := toLine(&shaper.orderer, input, tc.dir)
if output.bounds.Min == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Min not populated", i)
}
if output.bounds.Max == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Max not populated", i)
}
output := toLine(shaper.faceToIndex, input, tc.dir)
if output.direction != tc.dir {
t.Errorf("line %d: expected direction %v, got %v", i, tc.dir, output.direction)
}
@@ -565,10 +567,10 @@ func TestComputeVisualOrder(t *testing.T) {
func FuzzLayout(f *testing.F) {
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
f.Add("د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.", true, uint8(10), uint16(200))
f.Add("د عرمثال dstي met لم aqل جدmوpمg lرe dرd لو عل ميrةsdiduntut lab renنيتذدagلaaiua.ئPocttأior رادرsاي mيrbلmnonaيdتد ماةعcلخ.", true, false, uint8(10), uint16(200))
shaper := testShaper(ltrFace, rtlFace)
f.Fuzz(func(t *testing.T, txt string, rtl bool, fontSize uint8, width uint16) {
f.Fuzz(func(t *testing.T, txt string, rtl bool, truncate bool, fontSize uint8, width uint16) {
locale := system.Locale{
Direction: system.LTR,
}
@@ -578,9 +580,14 @@ func FuzzLayout(f *testing.F) {
if fontSize < 1 {
fontSize = 1
}
maxLines := 0
if truncate {
maxLines = 1
}
lines := shaper.LayoutRunes(Parameters{
PxPerEm: fixed.I(int(fontSize)),
MaxWidth: int(width),
MaxLines: maxLines,
Locale: locale,
}, []rune(txt))
validateLines(t, lines.lines, len([]rune(txt)))
@@ -591,12 +598,6 @@ func validateLines(t *testing.T, lines []line, expectedRuneCount int) {
t.Helper()
runesSeen := 0
for i, line := range lines {
if line.bounds.Min == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Min not populated", i)
}
if line.bounds.Max == (fixed.Point26_6{}) {
t.Errorf("line %d: Bounds.Max not populated", i)
}
totalRunWidth := fixed.I(0)
totalLineGlyphs := 0
lineRunesSeen := 0
@@ -662,106 +663,6 @@ func TestTextAppend(t *testing.T) {
}
}
func TestClosestFontByWeight(t *testing.T) {
const (
testTF1 giofont.Typeface = "MockFace"
testTF2 giofont.Typeface = "TestFace"
testTF3 giofont.Typeface = "AnotherFace"
)
fonts := []giofont.Font{
{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Normal},
{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light},
{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Bold},
{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Thin},
}
weightOnlyTests := []struct {
Lookup giofont.Weight
Expected giofont.Weight
}{
// Test for existing weights.
{Lookup: giofont.Normal, Expected: giofont.Normal},
{Lookup: giofont.Light, Expected: giofont.Light},
{Lookup: giofont.Bold, Expected: giofont.Bold},
// Test for missing weights.
{Lookup: giofont.Thin, Expected: giofont.Light},
{Lookup: giofont.ExtraLight, Expected: giofont.Light},
{Lookup: giofont.Medium, Expected: giofont.Normal},
{Lookup: giofont.SemiBold, Expected: giofont.Bold},
{Lookup: giofont.ExtraBold, Expected: giofont.Bold},
}
for _, test := range weightOnlyTests {
got, ok := closestFont(giofont.Font{Typeface: testTF1, Weight: test.Lookup}, fonts)
if !ok {
t.Errorf("expected closest font for %v to exist", test.Lookup)
}
if got.Weight != test.Expected {
t.Errorf("got weight %v, expected %v", got.Weight, test.Expected)
}
}
fonts = []giofont.Font{
{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light},
{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Bold},
{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal},
{Typeface: testTF3, Style: giofont.Italic, Weight: giofont.Bold},
}
otherTests := []struct {
Lookup giofont.Font
Expected giofont.Font
ExpectedToFail bool
}{
// Test for existing fonts.
{
Lookup: giofont.Font{Typeface: testTF1, Weight: giofont.Light},
Expected: giofont.Font{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light},
},
{
Lookup: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal},
Expected: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal},
},
// Test for missing fonts.
{
Lookup: giofont.Font{Typeface: testTF1, Weight: giofont.Normal},
Expected: giofont.Font{Typeface: testTF1, Style: giofont.Regular, Weight: giofont.Light},
},
{
Lookup: giofont.Font{Typeface: testTF3, Style: giofont.Italic, Weight: giofont.Normal},
Expected: giofont.Font{Typeface: testTF3, Style: giofont.Italic, Weight: giofont.Bold},
},
{
Lookup: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Thin},
Expected: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal},
},
{
Lookup: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Bold},
Expected: giofont.Font{Typeface: testTF1, Style: giofont.Italic, Weight: giofont.Normal},
},
{
Lookup: giofont.Font{Typeface: testTF2, Weight: giofont.Normal},
ExpectedToFail: true,
},
{
Lookup: giofont.Font{Typeface: testTF2, Style: giofont.Italic, Weight: giofont.Normal},
ExpectedToFail: true,
},
}
for _, test := range otherTests {
got, ok := closestFont(test.Lookup, fonts)
if test.ExpectedToFail {
if ok {
t.Errorf("expected closest font for %v to not exist", test.Lookup)
} else {
continue
}
}
if !ok {
t.Errorf("expected closest font for %v to exist", test.Lookup)
}
if got != test.Expected {
t.Errorf("got %v, expected %v", got, test.Expected)
}
}
}
func TestGlyphIDPacking(t *testing.T) {
const maxPPem = fixed.Int26_6((1 << sizebits) - 1)
type testcase struct {
+21 -20
View File
@@ -3,9 +3,8 @@
package text
import (
"encoding/binary"
"hash/maphash"
"image"
"sync/atomic"
giofont "gioui.org/font"
"gioui.org/io/system"
@@ -88,32 +87,32 @@ type glyphValue[V any] struct {
}
type glyphLRU[V any] struct {
seed maphash.Seed
seed uint64
cache lru[uint64, glyphValue[V]]
}
var seed uint32
// hashGlyphs computes a hash key based on the ID and X offset of
// every glyph in the slice.
func (c *glyphLRU[V]) hashGlyphs(gs []Glyph) uint64 {
if c.seed == (maphash.Seed{}) {
c.seed = maphash.MakeSeed()
if c.seed == 0 {
c.seed = uint64(atomic.AddUint32(&seed, 3900798947))
}
var h maphash.Hash
h.SetSeed(c.seed)
var b [8]byte
firstX := fixed.Int26_6(0)
for i, g := range gs {
if i == 0 {
firstX = g.X
}
// Cache glyph X offsets relative to the first glyph.
binary.LittleEndian.PutUint32(b[:4], uint32(g.X-firstX))
h.Write(b[:4])
binary.LittleEndian.PutUint64(b[:], uint64(g.ID))
h.Write(b[:])
if len(gs) == 0 {
return 0
}
sum := h.Sum64()
return sum
h := c.seed
firstX := gs[0].X
for _, g := range gs {
h += uint64(g.X - firstX)
h *= 6585573582091643
h += uint64(g.ID)
h *= 3650802748644053
}
return h
}
func (c *glyphLRU[V]) Get(key uint64, gs []Glyph) (V, bool) {
@@ -160,6 +159,8 @@ type layoutKey struct {
font giofont.Font
forceTruncate bool
wrapPolicy WrapPolicy
lineHeight fixed.Int26_6
lineHeightScale float32
}
type pathKey struct {
+78 -26
View File
@@ -62,6 +62,16 @@ type Parameters struct {
// Locale provides primary direction and language information for the shaped text.
Locale system.Locale
// LineHeightScale is a scaling factor applied to the LineHeight of a paragraph. If zero, a default
// value of 1.2 will be used.
LineHeightScale float32
// LineHeight is the distance between the baselines of two lines of text. If zero, the PxPerEm
// of the any given paragraph will set the LineHeight of that paragraph. This value will be
// scaled by LineHeightScale, so applications desiring a specific fixed value
// should set LineHeightScale to 1.
LineHeight fixed.Int26_6
// forceTruncate controls whether the truncator string is inserted on the final line of
// text with a MaxLines. It is unexported because this behavior only makes sense for the
// shaper to control when it iterates paragraphs of text.
@@ -111,7 +121,7 @@ type Glyph struct {
// belongs to. If Flags does not contain FlagClusterBreak, this value will
// always be zero. The final glyph in the cluster contains the runes count
// for the entire cluster.
Runes int
Runes uint16
// Flags encode special properties of this glyph.
Flags Flags
}
@@ -191,6 +201,11 @@ type GlyphID uint64
// Shaper converts strings of text into glyphs that can be displayed.
type Shaper struct {
config struct {
disableSystemFonts bool
collection []FontFace
}
initialized bool
shaper shaperImpl
pathCache pathCache
bitmapShapeCache bitmapShapeCache
@@ -213,26 +228,56 @@ type Shaper struct {
err error
}
// NewShaper constructs a shaper with the provided collection of font faces
// available.
func NewShaper(collection []FontFace) *Shaper {
l := &Shaper{}
for _, f := range collection {
l.shaper.Load(f)
// ShaperOptions configure text shapers.
type ShaperOption func(*Shaper)
// NoSystemFonts can be used to disable system font loading.
func NoSystemFonts() ShaperOption {
return func(s *Shaper) {
s.config.disableSystemFonts = true
}
l.shaper.shaper.SetFontCacheSize(32)
l.reader = bufio.NewReader(nil)
}
// WithCollection can be used to provide a collection of pre-loaded fonts to the shaper.
func WithCollection(collection []FontFace) ShaperOption {
return func(s *Shaper) {
s.config.collection = collection
}
}
// 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 {
opt(l)
}
l.init()
return l
}
func (l *Shaper) init() {
if l.initialized {
return
}
l.initialized = true
l.reader = bufio.NewReader(nil)
l.shaper = *newShaperImpl(!l.config.disableSystemFonts, l.config.collection)
}
// Layout text from an io.Reader according to a set of options. Results can be retrieved by
// iteratively calling NextGlyph.
func (l *Shaper) Layout(params Parameters, txt io.Reader) {
l.init()
l.layoutText(params, txt, "")
}
// LayoutString is Layout for strings.
func (l *Shaper) LayoutString(params Parameters, str string) {
l.init()
l.layoutText(params, nil, str)
}
@@ -274,7 +319,9 @@ func (l *Shaper) layoutText(params Parameters, txt io.Reader, str string) {
if !done {
_, re := l.reader.ReadByte()
done = re != nil
_ = l.reader.UnreadByte()
if !done {
_ = l.reader.UnreadByte()
}
}
} else {
idx := strings.IndexByte(str, '\n')
@@ -283,6 +330,7 @@ func (l *Shaper) layoutText(params Parameters, txt io.Reader, str string) {
endByte = len(str)
} else {
endByte = idx + 1
done = endByte == len(str)
}
}
if len(str[:endByte]) > 0 || (len(l.paragraph) > 0 || len(l.txt.lines) == 0) {
@@ -306,11 +354,7 @@ func (l *Shaper) layoutText(params Parameters, txt io.Reader, str string) {
unreadRunes++
}
}
lastLineIdx := len(lines.lines) - 1
lastRunIdx := len(lines.lines[lastLineIdx].runs) - 1
lastGlyphIdx := len(lines.lines[lastLineIdx].runs[lastRunIdx].Glyphs) - 1
lines.lines[lastLineIdx].runs[lastRunIdx].Runes.Count += unreadRunes
lines.lines[lastLineIdx].runs[lastRunIdx].Glyphs[lastGlyphIdx].runeCount += unreadRunes
l.txt.unreadRuneCount = unreadRunes
}
}
l.txt.append(lines)
@@ -334,16 +378,18 @@ func (l *Shaper) layoutParagraph(params Parameters, asStr string, asBytes []byte
}
// Alignment is not part of the cache key because changing it does not impact shaping.
lk := layoutKey{
ppem: params.PxPerEm,
maxWidth: params.MaxWidth,
minWidth: params.MinWidth,
maxLines: params.MaxLines,
truncator: params.Truncator,
locale: params.Locale,
font: params.Font,
forceTruncate: params.forceTruncate,
wrapPolicy: params.WrapPolicy,
str: asStr,
ppem: params.PxPerEm,
maxWidth: params.MaxWidth,
minWidth: params.MinWidth,
maxLines: params.MaxLines,
truncator: params.Truncator,
locale: params.Locale,
font: params.Font,
forceTruncate: params.forceTruncate,
wrapPolicy: params.WrapPolicy,
str: asStr,
lineHeight: params.LineHeight,
lineHeightScale: params.LineHeightScale,
}
if l, ok := l.layoutCache.Get(lk); ok {
return l
@@ -356,6 +402,7 @@ func (l *Shaper) layoutParagraph(params Parameters, asStr string, asBytes []byte
// NextGlyph returns the next glyph from the most recent shaping operation, if
// any. If there are no more glyphs, ok will be false.
func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
l.init()
if l.done {
return Glyph{}, false
}
@@ -422,7 +469,7 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
Ascent: line.ascent,
Descent: line.descent,
Advance: g.xAdvance,
Runes: g.runeCount,
Runes: uint16(g.runeCount),
Offset: fixed.Point26_6{
X: g.xOffset,
Y: g.yOffset,
@@ -457,6 +504,9 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
}
if endOfCluster {
glyph.Flags |= FlagClusterBreak
if run.truncator {
glyph.Runes += uint16(l.txt.unreadRuneCount)
}
} else {
glyph.Runes = 0
}
@@ -523,6 +573,7 @@ func splitGlyphID(g GlyphID) (fixed.Int26_6, int, font.GID) {
// of all vector glyphs.
// All glyphs are expected to be from a single line of text (their Y offsets are ignored).
func (l *Shaper) Shape(gs []Glyph) clip.PathSpec {
l.init()
key := l.pathCache.hashGlyphs(gs)
shape, ok := l.pathCache.Get(key, gs)
if ok {
@@ -539,6 +590,7 @@ func (l *Shaper) Shape(gs []Glyph) clip.PathSpec {
// same gs slice.
// All glyphs are expected to be from a single line of text (their Y offsets are ignored).
func (l *Shaper) Bitmaps(gs []Glyph) op.CallOp {
l.init()
key := l.bitmapShapeCache.hashGlyphs(gs)
call, ok := l.bitmapShapeCache.Get(key, gs)
if ok {
+109 -28
View File
@@ -6,6 +6,7 @@ import (
"testing"
nsareg "eliasnaur.com/font/noto/sans/arabic/regular"
"gioui.org/font"
"gioui.org/font/gofont"
"gioui.org/font/opentype"
"gioui.org/io/system"
@@ -22,7 +23,7 @@ func TestWrappingTruncation(t *testing.T) {
textInput := "Lorem ipsum dolor sit amet, consectetur adipiscing elit,\nsed do eiusmod tempor incididunt ut labore et\ndolore magna aliqua.\n"
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(collection)
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
@@ -50,9 +51,9 @@ func TestWrappingTruncation(t *testing.T) {
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
if g.Flags&FlagTruncator != 0 && g.Flags&FlagClusterBreak != 0 {
truncatedRunes += g.Runes
truncatedRunes += int(g.Runes)
} else {
untruncatedRunes += g.Runes
untruncatedRunes += int(g.Runes)
}
if g.Flags&FlagLineBreak != 0 {
lineCount++
@@ -89,7 +90,7 @@ func TestWrappingForcedTruncation(t *testing.T) {
textInput := "Lorem ipsum\ndolor sit\namet"
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(collection)
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
@@ -116,9 +117,9 @@ func TestWrappingForcedTruncation(t *testing.T) {
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
if g.Flags&FlagTruncator != 0 && g.Flags&FlagClusterBreak != 0 {
truncatedRunes += g.Runes
truncatedRunes += int(g.Runes)
} else {
untruncatedRunes += g.Runes
untruncatedRunes += int(g.Runes)
}
if g.Flags&FlagLineBreak != 0 {
lineCount++
@@ -153,23 +154,53 @@ func TestWrappingForcedTruncation(t *testing.T) {
// consistently and does not create spurious lines of text.
func TestShapingNewlineHandling(t *testing.T) {
type testcase struct {
textInput string
expectedLines int
expectedGlyphs int
textInput string
expectedLines int
expectedGlyphs int
maxLines int
expectedTruncated int
}
for _, tc := range []testcase{
{textInput: "a\n", expectedLines: 1, expectedGlyphs: 3},
{textInput: "a\nb", expectedLines: 2, expectedGlyphs: 3},
{textInput: "", expectedLines: 1, expectedGlyphs: 1},
{textInput: "\n", expectedLines: 1, expectedGlyphs: 2},
{textInput: "\n\n", expectedLines: 2, expectedGlyphs: 3},
{textInput: "\n\n\n", expectedLines: 3, expectedGlyphs: 4},
{textInput: "\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 1},
{textInput: "\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 2},
{textInput: "\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 1, expectedTruncated: 3},
{textInput: "a\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 1},
{textInput: "a\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 2},
{textInput: "a\n\n\n", expectedLines: 1, maxLines: 1, expectedGlyphs: 2, expectedTruncated: 3},
{textInput: "\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 2},
{textInput: "\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 1},
{textInput: "\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 2, expectedTruncated: 2},
{textInput: "a\n", expectedLines: 1, maxLines: 2, expectedGlyphs: 3},
{textInput: "a\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 1},
{textInput: "a\n\n\n", expectedLines: 2, maxLines: 2, expectedGlyphs: 3, expectedTruncated: 2},
} {
t.Run(fmt.Sprintf("%q", tc.textInput), func(t *testing.T) {
t.Run(fmt.Sprintf("%q-maxLines%d", tc.textInput, tc.maxLines), func(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(collection)
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
checkGlyphs := func() {
glyphs := []Glyph{}
runes := 0
truncated := 0
for g, ok := cache.NextGlyph(); ok; g, ok = cache.NextGlyph() {
glyphs = append(glyphs, g)
if g.Flags&FlagTruncator == 0 {
runes += int(g.Runes)
} else {
truncated += int(g.Runes)
}
}
if expected := len([]rune(tc.textInput)) - tc.expectedTruncated; expected != runes {
t.Errorf("expected %d runes, got %d", expected, runes)
}
if truncated != tc.expectedTruncated {
t.Errorf("expected %d truncated runes, got %d", tc.expectedTruncated, truncated)
}
if len(glyphs) != tc.expectedGlyphs {
t.Errorf("expected %d glyphs, got %d", tc.expectedGlyphs, len(glyphs))
@@ -191,36 +222,34 @@ func TestShapingNewlineHandling(t *testing.T) {
}
breakX, breakY := breakGlyph.X, breakGlyph.Y
startX, startY := startGlyph.X, startGlyph.Y
if breakX == startX {
t.Errorf("expected paragraph start glyph to have cursor x")
if breakX == startX && idx != 0 {
t.Errorf("expected paragraph start glyph to have cursor x, got %v", startX)
}
if breakY == startY {
t.Errorf("expected paragraph start glyph to have cursor y")
}
}
if count := strings.Count(tc.textInput, "\n"); found != count {
if count := strings.Count(tc.textInput, "\n"); found != count && tc.maxLines == 0 {
t.Errorf("expected %d paragraph breaks, found %d", count, found)
} else if tc.maxLines > 0 && found > tc.maxLines {
t.Errorf("expected %d paragraph breaks due to truncation, found %d", tc.maxLines, found)
}
}
cache.LayoutString(Parameters{
params := Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, tc.textInput)
MaxLines: tc.maxLines,
}
cache.LayoutString(params, tc.textInput)
if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
t.Errorf("shaping string %q created %d lines", tc.textInput, lineCount)
}
checkGlyphs()
cache.Layout(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
MinWidth: 200,
MaxWidth: 200,
Locale: english,
}, strings.NewReader(tc.textInput))
cache.Layout(params, strings.NewReader(tc.textInput))
if lineCount := len(cache.txt.lines); lineCount > tc.expectedLines {
t.Errorf("shaping reader %q created %d lines", tc.textInput, lineCount)
}
@@ -234,7 +263,7 @@ func TestShapingNewlineHandling(t *testing.T) {
func TestCacheEmptyString(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(collection)
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
Alignment: Middle,
PxPerEm: fixed.I(10),
@@ -273,7 +302,7 @@ func TestCacheEmptyString(t *testing.T) {
func TestCacheAlignment(t *testing.T) {
ltrFace, _ := opentype.Parse(goregular.TTF)
collection := []FontFace{{Face: ltrFace}}
cache := NewShaper(collection)
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
params := Parameters{
Alignment: Start,
PxPerEm: fixed.I(10),
@@ -339,7 +368,7 @@ func TestCacheGlyphConverstion(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
cache := NewShaper(collection)
cache := NewShaper(NoSystemFonts(), WithCollection(collection))
cache.LayoutString(Parameters{
PxPerEm: fixed.I(10),
MaxWidth: 200,
@@ -464,6 +493,58 @@ func TestShapeStringRuneAccounting(t *testing.T) {
MaxWidth: 100,
},
},
{
name: "newline regression",
input: "\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 999929,
},
},
{
name: "newline zero-width regression",
input: "\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 0,
},
},
{
name: "double newline regression",
input: "\n\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 1000,
},
},
{
name: "triple newline regression",
input: "\n\n\n",
params: Parameters{
Font: font.Font{Typeface: "Go", Style: font.Regular, Weight: font.Normal},
Alignment: Start,
PxPerEm: 768,
MaxLines: 1,
Truncator: "\u200b",
WrapPolicy: WrapHeuristically,
MaxWidth: 1000,
},
},
} {
t.Run(tc.name, func(t *testing.T) {
for _, setup := range []setup{
@@ -481,7 +562,7 @@ func TestShapeStringRuneAccounting(t *testing.T) {
},
} {
t.Run(setup.kind, func(t *testing.T) {
shaper := NewShaper(gofont.Collection())
shaper := NewShaper(NoSystemFonts(), WithCollection(gofont.Collection()))
setup.do(shaper, tc.params, tc.input)
glyphs := []Glyph{}
@@ -490,7 +571,7 @@ func TestShapeStringRuneAccounting(t *testing.T) {
}
totalRunes := 0
for _, g := range glyphs {
totalRunes += g.Runes
totalRunes += int(g.Runes)
}
if inputRunes := len([]rune(tc.input)); totalRunes != inputRunes {
t.Errorf("input contained %d runes, but glyphs contained %d", inputRunes, totalRunes)
@@ -1,5 +1,6 @@
go test fuzz v1
string("\x1d")
bool(true)
bool(false)
byte('\x1c')
uint16(227)
@@ -1,5 +1,6 @@
go test fuzz v1
string("0")
bool(true)
bool(false)
uint8(27)
uint16(200)
+6
View File
@@ -0,0 +1,6 @@
go test fuzz v1
string("\n")
bool(false)
bool(true)
byte('±')
uint16(0)
+6
View File
@@ -0,0 +1,6 @@
go test fuzz v1
string("\n")
bool(false)
bool(false)
byte('±')
uint16(0)
@@ -1,5 +1,6 @@
go test fuzz v1
string("\u2029")
bool(false)
bool(false)
byte('*')
uint16(72)
@@ -1,5 +1,6 @@
go test fuzz v1
string("Aͮ000000000000000")
bool(false)
bool(false)
byte('\u0087')
uint16(111)
@@ -1,5 +1,6 @@
go test fuzz v1
string("\x1e")
bool(true)
bool(false)
byte('\n')
uint16(254)
+6
View File
@@ -0,0 +1,6 @@
go test fuzz v1
string("000000000000000 00000000 ٰ00000")
bool(true)
bool(false)
byte('\n')
uint16(121)
@@ -1,5 +1,6 @@
go test fuzz v1
string("\r")
bool(false)
bool(false)
byte('T')
uint16(200)
@@ -1,5 +1,6 @@
go test fuzz v1
string("\u0085")
bool(true)
bool(false)
byte('\x10')
uint16(271)
@@ -1,5 +1,6 @@
go test fuzz v1
string("0")
bool(false)
bool(false)
byte('\x00')
uint16(142)
@@ -1,5 +1,6 @@
go test fuzz v1
string("\n")
bool(true)
bool(false)
byte('\t')
uint16(200)
@@ -1,5 +1,6 @@
go test fuzz v1
string("ع0 ׂ0")
bool(false)
bool(false)
byte('\u0098')
uint16(198)
@@ -1,5 +1,6 @@
go test fuzz v1
string("\x1c")
bool(true)
bool(false)
byte('\u009c')
uint16(200)
+9 -12
View File
@@ -11,15 +11,15 @@ type Bool struct {
Value bool
clk Clickable
changed bool
}
// Changed reports whether Value has changed since the last
// call to Changed.
func (b *Bool) Changed() bool {
changed := b.changed
b.changed = false
// Update the widget state and report whether Value was changed.
func (b *Bool) Update(gtx layout.Context) bool {
changed := false
for b.clk.Clicked(gtx) {
b.Value = !b.Value
changed = true
}
return changed
}
@@ -43,13 +43,10 @@ func (b *Bool) History() []Press {
}
func (b *Bool) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
b.Update(gtx)
dims := b.clk.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
for b.clk.Clicked() {
b.Value = !b.Value
b.changed = true
}
semantic.SelectedOp(b.Value).Add(gtx.Ops)
semantic.DisabledOp(gtx.Queue == nil).Add(gtx.Ops)
semantic.EnabledOp(gtx.Queue != nil).Add(gtx.Ops)
return w(gtx)
})
return dims
+76 -68
View File
@@ -17,17 +17,16 @@ import (
// Clickable represents a clickable area.
type Clickable struct {
click gesture.Click
clicks []Click
// prevClicks is the index into clicks that marks the clicks
// from the most recent Layout call. prevClicks is used to keep
// clicks bounded.
prevClicks int
history []Press
click gesture.Click
// clicks is for saved clicks to support Clicked.
clicks []Click
history []Press
keyTag struct{}
requestFocus bool
focused bool
keyTag struct{}
requestFocus bool
requestClicks int
focused bool
pressedKey string
}
// Click represents a click.
@@ -49,26 +48,24 @@ type Press struct {
Cancelled bool
}
// Click executes a simple programmatic click
// Click executes a simple programmatic click.
func (b *Clickable) Click() {
b.clicks = append(b.clicks, Click{
Modifiers: 0,
NumClicks: 1,
})
b.requestClicks++
}
// Clicked reports whether there are pending clicks as would be
// reported by Clicks. If so, Clicked removes the earliest click.
func (b *Clickable) Clicked() bool {
if len(b.clicks) == 0 {
return false
// Clicked reports whether there are pending clicks. If so, Clicked
// removes the earliest click.
func (b *Clickable) Clicked(gtx layout.Context) bool {
if len(b.clicks) > 0 {
b.clicks = b.clicks[1:]
return true
}
n := copy(b.clicks, b.clicks[1:])
b.clicks = b.clicks[:n]
if b.prevClicks > 0 {
b.prevClicks--
b.clicks = b.Update(gtx)
if len(b.clicks) > 0 {
b.clicks = b.clicks[1:]
return true
}
return true
return false
}
// Hovered reports whether a pointer is over the element.
@@ -91,44 +88,44 @@ func (b *Clickable) Focused() bool {
return b.focused
}
// Clicks returns and clear the clicks since the last call to Clicks.
func (b *Clickable) Clicks() []Click {
clicks := b.clicks
b.clicks = nil
b.prevClicks = 0
return clicks
}
// History is the past pointer presses useful for drawing markers.
// History is retained for a short duration (about a second).
func (b *Clickable) History() []Press {
return b.history
}
// Layout and update the button state
// Layout and update the button state.
func (b *Clickable) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
b.update(gtx)
b.Update(gtx)
m := op.Record(gtx.Ops)
dims := w(gtx)
c := m.Stop()
defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
disabled := gtx.Queue == nil
semantic.DisabledOp(disabled).Add(gtx.Ops)
enabled := gtx.Queue != nil
semantic.EnabledOp(enabled).Add(gtx.Ops)
b.click.Add(gtx.Ops)
if !disabled {
if enabled {
keys := key.Set("⏎|Space")
if !b.focused {
keys = ""
}
key.InputOp{Tag: &b.keyTag, Keys: keys}.Add(gtx.Ops)
if b.requestFocus {
key.FocusOp{Tag: &b.keyTag}.Add(gtx.Ops)
b.requestFocus = false
}
} else {
b.focused = false
}
c.Add(gtx.Ops)
return dims
}
// Update the button state by processing events, and return the resulting
// clicks, if any.
func (b *Clickable) Update(gtx layout.Context) []Click {
b.clicks = nil
if gtx.Queue == nil {
b.focused = false
}
if b.requestFocus {
key.FocusOp{Tag: &b.keyTag}.Add(gtx.Ops)
b.requestFocus = false
}
for len(b.history) > 0 {
c := b.history[0]
if c.End.IsZero() || gtx.Now.Sub(c.End) < 1*time.Second {
@@ -137,34 +134,31 @@ func (b *Clickable) Layout(gtx layout.Context, w layout.Widget) layout.Dimension
n := copy(b.history, b.history[1:])
b.history = b.history[:n]
}
return dims
}
// update the button state by processing events.
func (b *Clickable) update(gtx layout.Context) {
// Flush clicks from before the last update.
n := copy(b.clicks, b.clicks[b.prevClicks:])
b.clicks = b.clicks[:n]
b.prevClicks = n
for _, e := range b.click.Events(gtx) {
switch e.Type {
case gesture.TypeClick:
b.clicks = append(b.clicks, Click{
Modifiers: e.Modifiers,
NumClicks: e.NumClicks,
})
var clicks []Click
if c := b.requestClicks; c > 0 {
b.requestClicks = 0
clicks = append(clicks, Click{
NumClicks: c,
})
}
for _, e := range b.click.Update(gtx) {
switch e.Kind {
case gesture.KindClick:
if l := len(b.history); l > 0 {
b.history[l-1].End = gtx.Now
}
case gesture.TypeCancel:
clicks = append(clicks, Click{
Modifiers: e.Modifiers,
NumClicks: e.NumClicks,
})
case gesture.KindCancel:
for i := range b.history {
b.history[i].Cancelled = true
if b.history[i].End.IsZero() {
b.history[i].End = gtx.Now
}
}
case gesture.TypePress:
case gesture.KindPress:
if e.Source == pointer.Mouse {
key.FocusOp{Tag: &b.keyTag}.Add(gtx.Ops)
}
@@ -178,17 +172,31 @@ func (b *Clickable) update(gtx layout.Context) {
switch e := e.(type) {
case key.FocusEvent:
b.focused = e.Focus
if !b.focused {
b.pressedKey = ""
}
case key.Event:
if !b.focused || e.State != key.Release {
if !b.focused {
break
}
if e.Name != key.NameReturn && e.Name != key.NameSpace {
break
}
b.clicks = append(b.clicks, Click{
Modifiers: e.Modifiers,
NumClicks: 1,
})
switch e.State {
case key.Press:
b.pressedKey = e.Name
case key.Release:
if b.pressedKey != e.Name {
break
}
// only register a key as a click if the key was pressed and released while this button was focused
b.pressedKey = ""
clicks = append(clicks, Click{
Modifiers: e.Modifiers,
NumClicks: 1,
})
}
}
}
return clicks
}
+104
View File
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: Unlicense OR MIT
package widget_test
import (
"image"
"testing"
"gioui.org/io/key"
"gioui.org/io/router"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/widget"
)
func TestClickable(t *testing.T) {
var (
ops op.Ops
r router.Router
b1 widget.Clickable
b2 widget.Clickable
)
gtx := layout.NewContext(&ops, system.FrameEvent{Queue: &r})
layout := func() {
b1.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(100, 100)}
})
// buttons are on top of each other but we only use focus and keyevents, so this is fine
b2.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(100, 100)}
})
}
frame := func() {
ops.Reset()
layout()
r.Frame(gtx.Ops)
}
// frame: request focus for button 1
b1.Focus()
frame()
// frame: gain focus for button 1
frame()
if !b1.Focused() {
t.Error("button 1 did not gain focus")
}
if b2.Focused() {
t.Error("button 2 should not have focus")
}
// frame: press & release return
frame()
r.Queue(
key.Event{
Name: key.NameReturn,
State: key.Press,
},
key.Event{
Name: key.NameReturn,
State: key.Release,
},
)
if !b1.Clicked(gtx) {
t.Error("button 1 did not get clicked when it got return press & release")
}
if b2.Clicked(gtx) {
t.Error("button 2 got clicked when it did not have focus")
}
// frame: press return down
r.Queue(
key.Event{
Name: key.NameReturn,
State: key.Press,
},
)
frame()
if b1.Clicked(gtx) {
t.Error("button 1 got clicked, even if it only got return press")
}
// frame: request focus for button 2
b2.Focus()
frame()
// frame: gain focus for button 2
frame()
if b1.Focused() {
t.Error("button 1 should not have focus")
}
if !b2.Focused() {
t.Error("button 2 did not gain focus")
}
// frame: release return
r.Queue(
key.Event{
Name: key.NameReturn,
State: key.Release,
},
)
frame()
if b1.Clicked(gtx) {
t.Error("button 1 got clicked, even if it had lost focus")
}
if b2.Clicked(gtx) {
t.Error("button 2 should not have been clicked, as it only got return release")
}
}
+28 -22
View File
@@ -12,12 +12,11 @@ import (
// Decorations handles the states of window decorations.
type Decorations struct {
clicks []Clickable
clicks map[int]*Clickable
resize [8]struct {
gesture.Hover
gesture.Drag
}
actions system.Action
maximized bool
}
@@ -35,22 +34,13 @@ func (d *Decorations) Clickable(action system.Action) *Clickable {
panic(fmt.Errorf("not a single action"))
}
idx := bits.TrailingZeros(uint(action))
if n := idx - len(d.clicks); n >= 0 {
d.clicks = append(d.clicks, make([]Clickable, n+1)...)
}
click := &d.clicks[idx]
if click.Clicked() {
if action == system.ActionMaximize {
if d.maximized {
d.maximized = false
d.actions |= system.ActionUnmaximize
} else {
d.maximized = true
d.actions |= system.ActionMaximize
}
} else {
d.actions |= action
click, found := d.clicks[idx]
if !found {
click = new(Clickable)
if d.clicks == nil {
d.clicks = make(map[int]*Clickable)
}
d.clicks[idx] = click
}
return click
}
@@ -66,11 +56,27 @@ func (d *Decorations) Perform(actions system.Action) {
}
}
// Actions returns the set of actions activated by the user.
func (d *Decorations) Actions() system.Action {
a := d.actions
d.actions = 0
return a
// Update the state and return the set of actions activated by the user.
func (d *Decorations) Update(gtx layout.Context) system.Action {
var actions system.Action
for idx, clk := range d.clicks {
if !clk.Clicked(gtx) {
continue
}
action := system.Action(1 << idx)
switch {
case action == system.ActionMaximize && d.maximized:
action = system.ActionUnmaximize
case action == system.ActionUnmaximize && !d.maximized:
action = system.ActionMaximize
}
switch action {
case system.ActionMaximize, system.ActionUnmaximize:
d.maximized = !d.maximized
}
actions |= action
}
return actions
}
// Maximized returns whether the window is maximized.
+26 -37
View File
@@ -17,41 +17,16 @@ type Draggable struct {
// Type contains the MIME type and matches transfer.SourceOp.
Type string
handle struct{}
drag gesture.Drag
click f32.Point
pos f32.Point
requested bool
request string
handle struct{}
drag gesture.Drag
click f32.Point
pos f32.Point
}
func (d *Draggable) Layout(gtx layout.Context, w, drag layout.Widget) layout.Dimensions {
if gtx.Queue == nil {
return w(gtx)
}
pos := d.pos
for _, ev := range d.drag.Events(gtx.Metric, gtx.Queue, gesture.Both) {
switch ev.Type {
case pointer.Press:
d.click = ev.Position
pos = f32.Point{}
case pointer.Drag, pointer.Release:
pos = ev.Position.Sub(d.click)
}
}
d.pos = pos
for _, ev := range gtx.Queue.Events(&d.handle) {
switch e := ev.(type) {
case transfer.RequestEvent:
d.requested = true
d.request = e.Type
case transfer.CancelEvent:
d.requested = false
d.request = ""
}
}
dims := w(gtx)
stack := clip.Rect{Max: dims.Size}.Push(gtx.Ops)
@@ -64,7 +39,7 @@ func (d *Draggable) Layout(gtx layout.Context, w, drag layout.Widget) layout.Dim
if drag != nil && d.drag.Pressed() {
rec := op.Record(gtx.Ops)
op.Offset(pos.Round()).Add(gtx.Ops)
op.Offset(d.pos.Round()).Add(gtx.Ops)
drag(gtx)
op.Defer(gtx.Ops, rec.Stop())
}
@@ -77,13 +52,27 @@ func (d *Draggable) Dragging() bool {
return d.drag.Dragging()
}
// Requested returns the MIME type, if any, for which the Draggable was requested to offer data.
func (d *Draggable) Requested() (mime string, requested bool) {
mime = d.request
requested = d.requested
d.requested = false
d.request = ""
return
// Update the draggable and returns the MIME type for which the Draggable was
// requested to offer data, if any
func (d *Draggable) Update(gtx layout.Context) (mime string, requested bool) {
pos := d.pos
for _, ev := range d.drag.Update(gtx.Metric, gtx.Queue, gesture.Both) {
switch ev.Kind {
case pointer.Press:
d.click = ev.Position
pos = f32.Point{}
case pointer.Drag, pointer.Release:
pos = ev.Position.Sub(d.click)
}
}
d.pos = pos
for _, ev := range gtx.Queue.Events(&d.handle) {
if e, ok := ev.(transfer.RequestEvent); ok {
return e.Type, true
}
}
return "", false
}
// Offer the data ready for a drop. Must be called after being Requested.
+3 -3
View File
@@ -39,15 +39,15 @@ func TestDraggable(t *testing.T) {
r.Queue(
pointer.Event{
Position: f32.Pt(10, 10),
Type: pointer.Press,
Kind: pointer.Press,
},
pointer.Event{
Position: f32.Pt(20, 10),
Type: pointer.Move,
Kind: pointer.Move,
},
pointer.Event{
Position: f32.Pt(20, 10),
Type: pointer.Release,
Kind: pointer.Release,
},
)
ofr := &offer{data: "hello"}
+27 -16
View File
@@ -35,6 +35,12 @@ type Editor struct {
text textView
// Alignment controls the alignment of text within the editor.
Alignment text.Alignment
// LineHeight determines the gap between baselines of text. If zero, a sensible
// default will be used.
LineHeight unit.Sp
// LineHeightScale is multiplied by LineHeight to determine the final gap
// between baselines. If zero, a sensible default will be used.
LineHeightScale float32
// SingleLine force the text to stay on a single line.
// SingleLine also sets the scrolling direction to
// horizontal.
@@ -219,7 +225,7 @@ func (e *Editor) processPointer(gtx layout.Context) {
axis = gesture.Vertical
smin, smax = sbounds.Min.Y, sbounds.Max.Y
}
sdist := e.scroller.Scroll(gtx.Metric, gtx, gtx.Now, axis)
sdist := e.scroller.Update(gtx.Metric, gtx, gtx.Now, axis)
var soff int
if e.SingleLine {
e.text.ScrollRel(sdist, 0)
@@ -232,8 +238,8 @@ func (e *Editor) processPointer(gtx layout.Context) {
switch evt := evt.(type) {
case gesture.ClickEvent:
switch {
case evt.Type == gesture.TypePress && evt.Source == pointer.Mouse,
evt.Type == gesture.TypeClick && evt.Source != pointer.Mouse:
case evt.Kind == gesture.KindPress && evt.Source == pointer.Mouse,
evt.Kind == gesture.KindClick && evt.Source != pointer.Mouse:
prevCaretPos, _ := e.text.Selection()
e.blinkStart = gtx.Now
e.text.MoveCoord(image.Point{
@@ -272,10 +278,10 @@ func (e *Editor) processPointer(gtx layout.Context) {
case pointer.Event:
release := false
switch {
case evt.Type == pointer.Release && evt.Source == pointer.Mouse:
case evt.Kind == pointer.Release && evt.Source == pointer.Mouse:
release = true
fallthrough
case evt.Type == pointer.Drag && evt.Source == pointer.Mouse:
case evt.Kind == pointer.Drag && evt.Source == pointer.Mouse:
if e.dragging {
e.blinkStart = gtx.Now
e.text.MoveCoord(image.Point{
@@ -299,10 +305,10 @@ func (e *Editor) processPointer(gtx layout.Context) {
func (e *Editor) clickDragEvents(gtx layout.Context) []event.Event {
var combinedEvents []event.Event
for _, evt := range e.clicker.Events(gtx) {
for _, evt := range e.clicker.Update(gtx) {
combinedEvents = append(combinedEvents, evt)
}
for _, evt := range e.dragger.Events(gtx.Metric, gtx, gesture.Both) {
for _, evt := range e.dragger.Update(gtx.Metric, gtx, gesture.Both) {
combinedEvents = append(combinedEvents, evt)
}
return combinedEvents
@@ -504,20 +510,17 @@ func (e *Editor) initBuffer() {
e.text.SetSource(e.buffer)
}
e.text.Alignment = e.Alignment
e.text.LineHeight = e.LineHeight
e.text.LineHeightScale = e.LineHeightScale
e.text.SingleLine = e.SingleLine
e.text.Mask = e.Mask
e.text.WrapPolicy = e.WrapPolicy
}
// Layout lays out the editor using the provided textMaterial as the paint material
// for the text glyphs+caret and the selectMaterial as the paint material for the
// selection rectangle.
func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectMaterial op.CallOp) layout.Dimensions {
// Update the state of the editor in response to input events.
func (e *Editor) Update(gtx layout.Context) {
e.initBuffer()
e.text.Update(gtx, lt, font, size, e.processEvents)
dims := e.layout(gtx, textMaterial, selectMaterial)
e.processEvents(gtx)
if e.focused {
// Notify IME of selection if it changed.
newSel := e.ime.selection
@@ -543,8 +546,16 @@ func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, siz
e.updateSnippet(gtx, e.ime.start, e.ime.end)
}
}
return dims
// Layout lays out the editor using the provided textMaterial as the paint material
// for the text glyphs+caret and the selectMaterial as the paint material for the
// selection rectangle.
func (e *Editor) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectMaterial op.CallOp) layout.Dimensions {
e.Update(gtx)
e.text.Layout(gtx, lt, font, size)
return e.layout(gtx, textMaterial, selectMaterial)
}
// updateSnippet adds a key.SnippetOp if the snippet content or position
+29 -71
View File
@@ -108,7 +108,7 @@ func TestEditorReadOnly(t *testing.T) {
key.FocusEvent{Focus: true},
},
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
e := new(Editor)
@@ -139,7 +139,7 @@ func TestEditorReadOnly(t *testing.T) {
// Type some new characters.
gtx.Ops.Reset()
gtx.Queue = &testQueue{events: []event.Event{key.EditEvent{Range: key.Range{Start: cStart2, End: cEnd2}, Text: "something else"}}}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.Update(gtx)
textContent2 := e.Text()
if textContent2 != textContent {
t.Errorf("readonly editor modified by key.EditEvent")
@@ -159,22 +159,22 @@ func TestEditorReadOnly(t *testing.T) {
gtx.Ops.Reset()
gtx.Queue = &testQueue{events: []event.Event{
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Buttons: pointer.ButtonPrimary,
Position: f32.Pt(float32(dims.Size.X)*.5, 5),
},
pointer.Event{
Type: pointer.Drag,
Kind: pointer.Drag,
Buttons: pointer.ButtonPrimary,
Position: layout.FPt(dims.Size).Mul(.5),
},
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Buttons: pointer.ButtonPrimary,
Position: layout.FPt(dims.Size).Mul(.5),
},
}}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.Update(gtx)
cStart3, cEnd3 := e.Selection()
if cStart3 == cStart2 || cEnd3 == cEnd2 {
t.Errorf("expected mouse interaction to change selection.")
@@ -187,7 +187,7 @@ func TestEditorConfigurations(t *testing.T) {
Constraints: layout.Exact(image.Pt(300, 300)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
sentence := "\n\n\n\n\n\n\n\n\n\n\n\nthe quick brown fox jumps over the lazy dog"
@@ -241,7 +241,7 @@ func TestEditor(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
@@ -285,44 +285,10 @@ func TestEditor(t *testing.T) {
e.MoveCaret(-3, -3)
assertCaret(t, e, 1, 1, len("æbc\na"))
e.text.Mask = '*'
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.Update(gtx)
assertCaret(t, e, 1, 1, len("æbc\na"))
e.MoveCaret(-3, -3)
assertCaret(t, e, 0, 2, len("æb"))
/*
NOTE(whereswaldon): it isn't possible to check the raw glyph data
like this anymore. How should we handle this?
e.Mask = '\U0001F92B'
e.Layout(gtx, cache, font, fontSize, op.CallOp{},op.CallOp{})
e.moveEnd(selectionClear)
assertCaret(t, e, 0, 3, len("æbc"))
// When a password mask is applied, it should replace all visible glyphs
spaces := 0
for _, r := range textSample {
if unicode.IsSpace(r) {
spaces++
}
}
nonSpaces := len([]rune(textSample)) - spaces
glyphCounts := make(map[int]int)
// This loop assumes a single-run text, which we know is safe here.
for _, line := range e.lines {
for _, glyph := range line.Runs[0].Glyphs {
glyphCounts[int(glyph.ID)]++
}
}
if len(glyphCounts) > 2 {
t.Errorf("masked text contained glyphs other than mask and whitespace")
}
for gid, count := range glyphCounts {
if count != spaces && count != nonSpaces {
t.Errorf("glyph with id %d occurred %d times, expected either %d or %d", gid, count, spaces, nonSpaces)
}
}
*/
// Test that moveLine applies x offsets from previous moves.
e.SetText("long line\nshort")
e.SetCaret(0, 0)
@@ -349,7 +315,7 @@ func TestEditorRTL(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: arabic,
}
cache := text.NewShaper(arabicCollection)
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(arabicCollection))
fontSize := unit.Sp(10)
font := font.Font{}
@@ -419,14 +385,14 @@ func TestEditorLigature(t *testing.T) {
if err != nil {
t.Skipf("failed parsing test font: %v", err)
}
cache := text.NewShaper([]font.FontFace{
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{
{
Font: font.Font{
Typeface: "Roboto",
},
Face: face,
},
})
}))
fontSize := unit.Sp(10)
font := font.Font{}
@@ -508,13 +474,14 @@ func TestEditorLigature(t *testing.T) {
// Ensure that all runes in the final cluster of a line are properly
// decoded when moving to the end of the line. This is a regression test.
e.text.MoveEnd(selectionClear)
// The first line was broken by line wrapping, not a newline character. As such,
// the cursor can reach the position after the final glyph (a space).
assertCaret(t, e, 0, 14, len("fflffl fflffl "))
// The first line was broken by line wrapping, not a newline character, and has a trailing
// whitespace. However, we should never be able to reach the "other side" of such a trailing
// whitespace glyph.
assertCaret(t, e, 0, 13, len("fflffl fflffl"))
e.text.MoveLines(1, selectionClear)
assertCaret(t, e, 1, 13, len("fflffl fflffl fflffl fflffl"))
e.text.MoveLines(-1, selectionClear)
assertCaret(t, e, 0, 14, len("fflffl fflffl "))
assertCaret(t, e, 0, 13, len("fflffl fflffl"))
// Absurdly narrow constraints to force each ligature onto its own line.
gtx.Constraints = layout.Exact(image.Pt(10, 10))
@@ -540,7 +507,7 @@ func TestEditorDimensions(t *testing.T) {
Queue: tq,
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
dims := e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
@@ -587,7 +554,7 @@ func TestEditorCaretConsistency(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
for _, a := range []text.Alignment{text.Start, text.Middle, text.End} {
@@ -679,11 +646,8 @@ func TestEditorMoveWord(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := font.Font{}
e.SetText(t)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.Update(gtx)
return e
}
for ii, tt := range tests {
@@ -784,11 +748,8 @@ func TestEditorInsert(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := font.Font{}
e.SetText(t)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.Update(gtx)
return e
}
for ii, tt := range tests {
@@ -874,11 +835,8 @@ func TestEditorDeleteWord(t *testing.T) {
Constraints: layout.Exact(image.Pt(100, 100)),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
fontSize := unit.Sp(10)
font := font.Font{}
e.SetText(t)
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
e.Update(gtx)
return e
}
for ii, tt := range tests {
@@ -928,7 +886,7 @@ g 2 4 6 8 g
Ops: new(op.Ops),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
font := font.Font{}
fontSize := unit.Sp(10)
@@ -946,13 +904,13 @@ g 2 4 6 8 g
events: []event.Event{
pointer.Event{
Buttons: pointer.ButtonPrimary,
Type: pointer.Press,
Kind: pointer.Press,
Source: pointer.Mouse,
Time: tim,
Position: f32.Pt(textWidth(e, startPos.lineCol.line, 0, startPos.lineCol.col), textBaseline(e, startPos.lineCol.line)),
},
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Source: pointer.Mouse,
Time: tim,
Position: f32.Pt(textWidth(e, endPos.lineCol.line, 0, endPos.lineCol.col), textBaseline(e, endPos.lineCol.line)),
@@ -1026,7 +984,7 @@ func TestSelectMove(t *testing.T) {
Ops: new(op.Ops),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
font := font.Font{}
fontSize := unit.Sp(10)
@@ -1114,7 +1072,7 @@ func TestEditor_MaxLen(t *testing.T) {
key.SelectionEvent{Start: 4, End: 4},
),
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
@@ -1145,7 +1103,7 @@ func TestEditor_Filter(t *testing.T) {
key.SelectionEvent{Start: 4, End: 4},
),
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
@@ -1169,7 +1127,7 @@ func TestEditor_Submit(t *testing.T) {
key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "ab1\n"},
),
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fontSize := unit.Sp(10)
font := font.Font{}
e.Layout(gtx, cache, font, fontSize, op.CallOp{}, op.CallOp{})
+53 -54
View File
@@ -20,8 +20,6 @@ type Enum struct {
focus string
focused bool
changed bool
keys []*enumKey
}
@@ -40,11 +38,55 @@ func (e *Enum) index(k string) *enumKey {
return nil
}
// Changed reports whether Value has changed by user interaction since the last
// call to Changed.
func (e *Enum) Changed() bool {
changed := e.changed
e.changed = false
// Update the state and report whether Value has changed by user interaction.
func (e *Enum) Update(gtx layout.Context) bool {
if gtx.Queue == nil {
e.focused = false
}
e.hovering = false
changed := false
for _, state := range e.keys {
for _, ev := range state.click.Update(gtx) {
switch ev.Kind {
case gesture.KindPress:
if ev.Source == pointer.Mouse {
key.FocusOp{Tag: &state.tag}.Add(gtx.Ops)
}
case gesture.KindClick:
if state.key != e.Value {
e.Value = state.key
changed = true
}
}
}
for _, ev := range gtx.Events(&state.tag) {
switch ev := ev.(type) {
case key.FocusEvent:
if ev.Focus {
e.focused = true
e.focus = state.key
} else if state.key == e.focus {
e.focused = false
}
case key.Event:
if !e.focused || ev.State != key.Release {
break
}
if ev.Name != key.NameReturn && ev.Name != key.NameSpace {
break
}
if state.key != e.Value {
e.Value = state.key
changed = true
}
}
}
if state.click.Hovered() {
e.hovered = state.key
e.hovering = true
}
}
return changed
}
@@ -60,6 +102,7 @@ func (e *Enum) Focused() (string, bool) {
// Layout adds the event handler for the key k.
func (e *Enum) Layout(gtx layout.Context, k string, content layout.Widget) layout.Dimensions {
e.Update(gtx)
m := op.Record(gtx.Ops)
dims := content(gtx)
c := m.Stop()
@@ -73,57 +116,13 @@ func (e *Enum) Layout(gtx layout.Context, k string, content layout.Widget) layou
e.keys = append(e.keys, state)
}
clk := &state.click
for _, ev := range clk.Events(gtx) {
switch ev.Type {
case gesture.TypePress:
if ev.Source == pointer.Mouse {
key.FocusOp{Tag: &state.tag}.Add(gtx.Ops)
}
case gesture.TypeClick:
if state.key != e.Value {
e.Value = state.key
e.changed = true
}
}
}
for _, ev := range gtx.Events(&state.tag) {
switch ev := ev.(type) {
case key.FocusEvent:
if ev.Focus {
e.focused = true
e.focus = state.key
} else if state.key == e.focus {
e.focused = false
}
case key.Event:
if !e.focused || ev.State != key.Release {
break
}
if ev.Name != key.NameReturn && ev.Name != key.NameSpace {
break
}
if state.key != e.Value {
e.Value = state.key
e.changed = true
}
}
}
if clk.Hovered() {
e.hovered = k
e.hovering = true
} else if e.hovered == k {
e.hovering = false
}
clk.Add(gtx.Ops)
disabled := gtx.Queue == nil
if !disabled {
enabled := gtx.Queue != nil
if enabled {
key.InputOp{Tag: &state.tag, Keys: "⏎|Space"}.Add(gtx.Ops)
} else if e.focus == k {
e.focused = false
}
semantic.SelectedOp(k == e.Value).Add(gtx.Ops)
semantic.DisabledOp(disabled).Add(gtx.Ops)
semantic.EnabledOp(enabled).Add(gtx.Ops)
c.Add(gtx.Ops)
return dims
+8 -10
View File
@@ -47,23 +47,21 @@ func ExampleClickable_passthrough() {
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(50, 50),
},
pointer.Event{
Source: pointer.Mouse,
Buttons: pointer.ButtonPrimary,
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(50, 50),
},
)
// The second layout ensures that the click event is registered by the buttons.
widget()
if button1.Clicked() {
if button1.Clicked(gtx) {
fmt.Println("button1 clicked!")
}
if button2.Clicked() {
if button2.Clicked(gtx) {
fmt.Println("button2 clicked!")
}
@@ -95,7 +93,7 @@ func ExampleDraggable_Layout() {
drag.Layout(gtx, w, w)
// drag must respond with an Offer event when requested.
// Use the drag method for this.
if m, ok := drag.Requested(); ok {
if m, ok := drag.Update(gtx); ok {
drag.Offer(gtx.Ops, m, offer{Data: "hello world"})
}
@@ -125,15 +123,15 @@ func ExampleDraggable_Layout() {
// Send drag and drop gesture events.
r.Queue(
pointer.Event{
Type: pointer.Press,
Kind: pointer.Press,
Position: f32.Pt(5, 5), // in the drag area
},
pointer.Event{
Type: pointer.Move,
Kind: pointer.Move,
Position: f32.Pt(5, 5), // in the drop area
},
pointer.Event{
Type: pointer.Release,
Kind: pointer.Release,
Position: f32.Pt(30, 30), // in the drop area
},
)
+29 -63
View File
@@ -9,59 +9,29 @@ import (
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/unit"
)
// Float is for selecting a value in a range.
type Float struct {
// Value is the value of the Float, in the [0; 1] range.
Value float32
drag gesture.Drag
pos float32 // position normalized to [0, 1]
length float32
changed bool
drag gesture.Drag
axis layout.Axis
length float32
}
// Dragging returns whether the value is being interacted with.
func (f *Float) Dragging() bool { return f.drag.Dragging() }
// Layout updates the value according to drag events along the f's main axis.
//
// The range of f is set by the minimum constraints main axis value.
func (f *Float) Layout(gtx layout.Context, axis layout.Axis, min, max float32, invert bool, pointerMargin int) layout.Dimensions {
func (f *Float) Layout(gtx layout.Context, axis layout.Axis, pointerMargin unit.Dp) layout.Dimensions {
f.Update(gtx)
size := gtx.Constraints.Min
f.length = float32(axis.Convert(size).X)
f.axis = axis
var de *pointer.Event
for _, e := range f.drag.Events(gtx.Metric, gtx, gesture.Axis(axis)) {
if e.Type == pointer.Press || e.Type == pointer.Drag {
de = &e
}
}
value := f.Value
if de != nil {
xy := de.Position.X
if axis == layout.Vertical {
xy = f.length - de.Position.Y
}
if invert {
xy = f.length - xy
}
f.pos = xy / f.length
value = min + (max-min)*f.pos
} else if min != max {
f.pos = (value - min) / (max - min)
}
// Unconditionally call setValue in case min, max, or value changed.
f.setValue(value, min, max)
if f.pos < 0 {
f.pos = 0
} else if f.pos > 1 {
f.pos = 1
}
margin := axis.Convert(image.Pt(pointerMargin, 0))
margin := axis.Convert(image.Pt(gtx.Dp(pointerMargin), 0))
rect := image.Rectangle{
Min: margin.Mul(-1),
Max: size.Add(margin),
@@ -72,30 +42,26 @@ func (f *Float) Layout(gtx layout.Context, axis layout.Axis, min, max float32, i
return layout.Dimensions{Size: size}
}
func (f *Float) setValue(value, min, max float32) {
if min > max {
min, max = max, min
// Update the Value according to drag events along the f's main axis.
// The return value reports whether the value was changed.
//
// The range of f is set by the minimum constraints main axis value.
func (f *Float) Update(gtx layout.Context) bool {
changed := false
for _, e := range f.drag.Update(gtx.Metric, gtx, gesture.Axis(f.axis)) {
if f.length > 0 && (e.Kind == pointer.Press || e.Kind == pointer.Drag) {
pos := e.Position.X
if f.axis == layout.Vertical {
pos = f.length - e.Position.Y
}
f.Value = pos / f.length
if f.Value < 0 {
f.Value = 0
} else if f.Value > 1 {
f.Value = 1
}
changed = true
}
}
if value < min {
value = min
} else if value > max {
value = max
}
if f.Value != value {
f.Value = value
f.changed = true
}
}
// Pos reports the selected position.
func (f *Float) Pos() float32 {
return f.pos * f.length
}
// Changed reports whether the value has changed since
// the last call to Changed.
func (f *Float) Changed() bool {
changed := f.changed
f.changed = false
return changed
}
+50 -38
View File
@@ -44,12 +44,11 @@ type glyphIndex struct {
prog text.Flags
// clusterAdvance accumulates the advances of glyphs in a glyph cluster.
clusterAdvance fixed.Int26_6
// skipPrior controls whether a text position is inserted "before" the
// next glyph. Usually this should not happen, but the boundaries of
// lines and bidi runs require it.
skipPrior bool
// truncated indicates that the text was truncated by the shaper.
truncated bool
// midCluster tracks whether the next glyph processed is not the first glyph in a
// cluster.
midCluster bool
}
// reset prepares the index for reuse.
@@ -63,8 +62,8 @@ func (g *glyphIndex) reset() {
g.pos = combinedPos{}
g.prog = 0
g.clusterAdvance = 0
g.skipPrior = false
g.truncated = false
g.midCluster = false
}
// screenPos represents a character position in text line and column numbers,
@@ -113,6 +112,20 @@ func (g *glyphIndex) incrementPosition(pos combinedPos) (next combinedPos, eof b
return candidate, true
}
func (g *glyphIndex) insertPosition(pos combinedPos) {
lastIdx := len(g.positions) - 1
if lastIdx >= 0 {
lastPos := g.positions[lastIdx]
if lastPos.runes == pos.runes && (lastPos.y != pos.y || (lastPos.x == pos.x)) {
// If we insert a consecutive position with the same logical position,
// overwrite the previous position with the new one.
g.positions[lastIdx] = pos
return
}
}
g.positions = append(g.positions, pos)
}
// Glyph indexes the provided glyph, generating text cursor positions for it.
func (g *glyphIndex) Glyph(gl text.Glyph) {
g.glyphs = append(g.glyphs, gl)
@@ -128,30 +141,32 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
if end := gl.X + gl.Advance; end > g.currentLineMax {
g.currentLineMax = end
}
if !g.skipPrior || gl.Flags&text.FlagTowardOrigin != g.prog || gl.Flags&text.FlagParagraphStart != 0 {
// Set the new text progression based on that of the first glyph.
g.prog = gl.Flags & text.FlagTowardOrigin
g.pos.towardOrigin = g.prog == text.FlagTowardOrigin
// Create the text position prior to the first glyph.
pos := g.pos
pos.x = gl.X
pos.y = int(gl.Y)
pos.ascent = gl.Ascent
pos.descent = gl.Descent
if pos.towardOrigin {
pos.x += gl.Advance
}
g.pos = pos
g.positions = append(g.positions, pos)
g.skipPrior = true
}
needsNewLine := gl.Flags&text.FlagLineBreak != 0
needsNewRun := gl.Flags&text.FlagRunBreak != 0
breaksParagraph := gl.Flags&text.FlagParagraphBreak != 0
breaksCluster := gl.Flags&text.FlagClusterBreak != 0
// We should insert new positions if the glyph we're processing terminates
// a glyph cluster.
insertPositionAfter := gl.Flags&text.FlagClusterBreak != 0 && !breaksParagraph && gl.Runes > 0
// a glyph cluster, has nonzero runes, and is not a hard newline.
insertPositionsWithin := breaksCluster && !breaksParagraph && gl.Runes > 0
// Get the text progression/direction right.
g.prog = gl.Flags & text.FlagTowardOrigin
g.pos.towardOrigin = g.prog == text.FlagTowardOrigin
if !g.midCluster {
// Create the text position prior to the glyph.
g.pos.x = gl.X
g.pos.y = int(gl.Y)
g.pos.ascent = gl.Ascent
g.pos.descent = gl.Descent
if g.pos.towardOrigin {
g.pos.x += gl.Advance
}
g.insertPosition(g.pos)
}
g.midCluster = !breaksCluster
if breaksParagraph {
// Paragraph breaking clusters shouldn't have positions generated for both
// sides of them. They're always zero-width, so doing so would
@@ -164,12 +179,11 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
// Always track the cumulative advance added by the glyph, even if it
// doesn't terminate a cluster itself.
g.clusterAdvance += gl.Advance
if insertPositionAfter {
// Construct the text position _after_ gl.
pos := g.pos
pos.y = int(gl.Y)
pos.ascent = gl.Ascent
pos.descent = gl.Descent
if insertPositionsWithin {
// Construct the text positions _within_ gl.
g.pos.y = int(gl.Y)
g.pos.ascent = gl.Ascent
g.pos.descent = gl.Descent
width := g.clusterAdvance
positionCount := int(gl.Runes)
runesPerPosition := 1
@@ -181,19 +195,18 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
}
perRune := width / fixed.Int26_6(positionCount)
adjust := fixed.Int26_6(0)
if pos.towardOrigin {
if g.pos.towardOrigin {
// If RTL, subtract increments from the width of the cluster
// instead of adding.
adjust = width
perRune = -perRune
}
for i := 1; i <= positionCount; i++ {
pos.x = gl.X + adjust + perRune*fixed.Int26_6(i)
pos.runes += runesPerPosition
pos.lineCol.col += runesPerPosition
g.positions = append(g.positions, pos)
g.pos.x = gl.X + adjust + perRune*fixed.Int26_6(i)
g.pos.runes += runesPerPosition
g.pos.lineCol.col += runesPerPosition
g.insertPosition(g.pos)
}
g.pos = pos
g.clusterAdvance = 0
}
if needsNewRun {
@@ -214,7 +227,6 @@ func (g *glyphIndex) Glyph(gl text.Glyph) {
g.currentLineMin = math.MaxInt32
g.currentLineMax = 0
g.currentLineGlyphs = 0
g.skipPrior = false
}
}
+130 -65
View File
@@ -20,7 +20,7 @@ func makePosTestText(fontSize, lineWidth int, alignOpposite bool) (source string
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := text.NewShaper([]font.FontFace{
shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{
{
Font: font.Font{Typeface: "LTR"},
Face: ltrFace,
@@ -29,12 +29,11 @@ func makePosTestText(fontSize, lineWidth int, alignOpposite bool) (source string
Font: font.Font{Typeface: "RTL"},
Face: rtlFace,
},
})
}))
// bidiSource is crafted to contain multiple consecutive RTL runs (by
// changing scripts within the RTL).
bidiSource := "The quick سماء שלום لا fox تمط שלום غير the lazy dog."
ltrParams := text.Parameters{
Font: font.Font{Typeface: "LTR"},
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
MinWidth: lineWidth,
@@ -42,7 +41,6 @@ func makePosTestText(fontSize, lineWidth int, alignOpposite bool) (source string
}
rtlParams := text.Parameters{
Alignment: text.End,
Font: font.Font{Typeface: "RTL"},
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
MinWidth: lineWidth,
@@ -69,7 +67,7 @@ func makeAccountingTestText(str string, fontSize, lineWidth int) (txt []text.Gly
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := text.NewShaper([]font.FontFace{{
shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{{
Font: font.Font{Typeface: "LTR"},
Face: ltrFace,
},
@@ -77,7 +75,7 @@ func makeAccountingTestText(str string, fontSize, lineWidth int) (txt []text.Gly
Font: font.Font{Typeface: "RTL"},
Face: rtlFace,
},
})
}))
params := text.Parameters{
PxPerEm: fixed.I(fontSize),
MaxWidth: lineWidth,
@@ -95,7 +93,7 @@ func getGlyphs(fontSize, minWidth, lineWidth int, align text.Alignment, str stri
ltrFace, _ := opentype.Parse(goregular.TTF)
rtlFace, _ := opentype.Parse(nsareg.TTF)
shaper := text.NewShaper([]font.FontFace{{
shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection([]font.FontFace{{
Font: font.Font{Typeface: "LTR"},
Face: ltrFace,
},
@@ -103,13 +101,14 @@ func getGlyphs(fontSize, minWidth, lineWidth int, align text.Alignment, str stri
Font: font.Font{Typeface: "RTL"},
Face: rtlFace,
},
})
}))
params := text.Parameters{
PxPerEm: fixed.I(fontSize),
Alignment: align,
MinWidth: minWidth,
MaxWidth: lineWidth,
Locale: english,
PxPerEm: fixed.I(fontSize),
Alignment: align,
MinWidth: minWidth,
MaxWidth: lineWidth,
Locale: english,
WrapPolicy: text.WrapWords,
}
shaper.LayoutString(params, str)
for g, ok := shaper.NextGlyph(); ok; g, ok = shaper.NextGlyph() {
@@ -122,30 +121,34 @@ func getGlyphs(fontSize, minWidth, lineWidth int, align text.Alignment, str stri
// for empty lines and the empty string.
func TestIndexPositionWhitespace(t *testing.T) {
type testcase struct {
name string
str string
align text.Alignment
expected []combinedPos
name string
str string
lineWidth int
align text.Alignment
expected []combinedPos
}
for _, tc := range []testcase{
{
name: "empty string",
str: "",
name: "empty string",
str: "",
lineWidth: 200,
expected: []combinedPos{
{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
},
},
{
name: "just hard newline",
str: "\n",
name: "just hard newline",
str: "\n",
lineWidth: 200,
expected: []combinedPos{
{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
{x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}},
},
},
{
name: "trailing newline",
str: "a\n",
name: "trailing newline",
str: "a\n",
lineWidth: 200,
expected: []combinedPos{
{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
{x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}},
@@ -153,8 +156,9 @@ func TestIndexPositionWhitespace(t *testing.T) {
},
},
{
name: "just blank line",
str: "\n\n",
name: "just blank line",
str: "\n\n",
lineWidth: 200,
expected: []combinedPos{
{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
{x: fixed.Int26_6(0), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}},
@@ -162,9 +166,10 @@ func TestIndexPositionWhitespace(t *testing.T) {
},
},
{
name: "middle aligned blank lines",
str: "\n\n\nabc",
align: text.Middle,
name: "middle aligned blank lines",
str: "\n\n\nabc",
align: text.Middle,
lineWidth: 200,
expected: []combinedPos{
{x: fixed.Int26_6(832), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
{x: fixed.Int26_6(832), y: 35, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{line: 1}},
@@ -176,8 +181,9 @@ func TestIndexPositionWhitespace(t *testing.T) {
},
},
{
name: "blank line",
str: "a\n\nb",
name: "blank line",
str: "a\n\nb",
lineWidth: 200,
expected: []combinedPos{
{x: fixed.Int26_6(0), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216)},
{x: fixed.Int26_6(570), y: 16, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 1, lineCol: screenPos{col: 1}},
@@ -186,9 +192,45 @@ func TestIndexPositionWhitespace(t *testing.T) {
{x: fixed.Int26_6(570), y: 54, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), runes: 4, lineCol: screenPos{line: 2, col: 1}},
},
},
{
name: "soft wrap",
str: "abc def",
lineWidth: 30,
expected: []combinedPos{
{runes: 0, lineCol: screenPos{line: 0, col: 0}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 0, y: 16},
{runes: 1, lineCol: screenPos{line: 0, col: 1}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 570, y: 16},
{runes: 2, lineCol: screenPos{line: 0, col: 2}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1140, y: 16},
{runes: 3, lineCol: screenPos{line: 0, col: 3}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1652, y: 16},
{runes: 4, lineCol: screenPos{line: 1, col: 0}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 0, y: 35},
{runes: 5, lineCol: screenPos{line: 1, col: 1}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 570, y: 35},
{runes: 6, lineCol: screenPos{line: 1, col: 2}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1140, y: 35},
{runes: 7, lineCol: screenPos{line: 1, col: 3}, ascent: fixed.Int26_6(968), descent: fixed.Int26_6(216), x: 1425, y: 35},
},
},
{
name: "soft wrap arabic",
str: "ثنائي الاتجاه",
lineWidth: 30,
expected: []combinedPos{
{runes: 0, lineCol: screenPos{line: 0, col: 0}, ascent: 1407, descent: 756, x: 2250, y: 22, towardOrigin: true},
{runes: 1, lineCol: screenPos{line: 0, col: 1}, ascent: 1407, descent: 756, x: 1944, y: 22, towardOrigin: true},
{runes: 2, lineCol: screenPos{line: 0, col: 2}, ascent: 1407, descent: 756, x: 1593, y: 22, towardOrigin: true},
{runes: 3, lineCol: screenPos{line: 0, col: 3}, ascent: 1407, descent: 756, x: 1295, y: 22, towardOrigin: true},
{runes: 4, lineCol: screenPos{line: 0, col: 4}, ascent: 1407, descent: 756, x: 1020, y: 22, towardOrigin: true},
{runes: 5, lineCol: screenPos{line: 0, col: 5}, ascent: 1407, descent: 756, x: 266, y: 22, towardOrigin: true},
{runes: 6, lineCol: screenPos{line: 1, col: 0}, ascent: 1407, descent: 756, x: 2511, y: 41, towardOrigin: true},
{runes: 7, lineCol: screenPos{line: 1, col: 1}, ascent: 1407, descent: 756, x: 2267, y: 41, towardOrigin: true},
{runes: 8, lineCol: screenPos{line: 1, col: 2}, ascent: 1407, descent: 756, x: 1969, y: 41, towardOrigin: true},
{runes: 9, lineCol: screenPos{line: 1, col: 3}, ascent: 1407, descent: 756, x: 1671, y: 41, towardOrigin: true},
{runes: 10, lineCol: screenPos{line: 1, col: 4}, ascent: 1407, descent: 756, x: 1365, y: 41, towardOrigin: true},
{runes: 11, lineCol: screenPos{line: 1, col: 5}, ascent: 1407, descent: 756, x: 713, y: 41, towardOrigin: true},
{runes: 12, lineCol: screenPos{line: 1, col: 6}, ascent: 1407, descent: 756, x: 415, y: 41, towardOrigin: true},
{runes: 13, lineCol: screenPos{line: 1, col: 7}, ascent: 1407, descent: 756, x: 0, y: 41, towardOrigin: true},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
glyphs := getGlyphs(16, 0, 200, tc.align, tc.str)
glyphs := getGlyphs(16, 0, tc.lineWidth, tc.align, tc.str)
var gi glyphIndex
gi.reset()
for _, g := range glyphs {
@@ -229,9 +271,12 @@ func TestIndexPositionBidi(t *testing.T) {
name: "bidi ltr",
glyphs: bidiLTRText,
expectedXs: []fixed.Int26_6{
0, 626, 1196, 1766, 2051, 2621, 3191, 3444, 3956, 4468, 4753, 7133, 6330, 5738, 5440, 5019, 4753, // Positions on line 0.
3953, 3185, 2417, 1649, 881, 596, 298, 0, 3953, 4238, 4523, 5093, 5605, 5890, 7905, 7599, 7007, 6156, 5890, // Positions on line 1.
4660, 3892, 3124, 2356, 1588, 1303, 788, 406, 0, 4660, 4945, 5235, 5805, 6375, 6660, 6934, 7504, 8016, 8528, 8813, // Positions on line 2.
0, 626, 1196, 1766, 2051, 2621, 3191, 3444, 3956, 4468, 4753, 7133, 6330, 5738, 5440, 5019, // Positions on line 0.
3953, 3185, 2417, 1649, 881, 596, 298, 0, 3953, 4238, 4523, 5093, 5605, 5890, 7905, 7599, 7007, 6156, // Positions on line 1.
4660, 3892, 3124, 2356, 1588, 1303, 788, 406, 0, 4660, 4945, 5235, 5805, 6375, 6660, 6934, 7504, 8016, 8528, // Positions on line 2.
0, 570, 1140, 1710, 2034, // Positions on line 3.
},
},
@@ -239,9 +284,13 @@ func TestIndexPositionBidi(t *testing.T) {
name: "bidi rtl",
glyphs: bidiRTLText,
expectedXs: []fixed.Int26_6{
5368, 5994, 6564, 7134, 7419, 7989, 8559, 8812, 9324, 9836, 5368, 5102, 4299, 3707, 3409, 2988, 2722, 2108, 1494, 880, 266, 0, // Positions on line 0.
8801, 8503, 8205, 7939, 6572, 6857, 7427, 7939, 6572, 6306, 6000, 5408, 4557, 4291, 3677, 3063, 2449, 1835, 1569, 1054, 672, 266, 0, // Positions on line 1.
274, 564, 1134, 1704, 1989, 2263, 2833, 3345, 3857, 4142, 4712, 5282, 5852, 274, 0, // Positions on line 2.
2665, 3291, 3861, 4431, 4716, 5286, 5856, 6109, 6621, 7133, 2665, 2380, 1577, 985, 687, 266, // Positions on line 0.
7886, 7118, 6350, 5582, 4814, 4529, 4231, 3933, 3667, 2300, 2585, 3155, 3667, 2300, 2015, 1709, 1117, 266, // Positions on line 1.
8794, 8026, 7258, 6490, 5722, 5437, 4922, 4540, 4134, 3868, 0, 290, 860, 1430, 1715, 1989, 2559, 3071, 3583, // Positions on line 2.
324, 894, 1464, 2034, 324, 0, // Positions on line 3.
},
},
} {
@@ -320,7 +369,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 56,
yOff: 41,
glyphs: 15,
width: fixed.Int26_6(7905),
ascent: fixed.Int26_6(1407),
@@ -328,7 +377,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 90,
yOff: 60,
glyphs: 18,
width: fixed.Int26_6(8813),
ascent: fixed.Int26_6(1407),
@@ -336,7 +385,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(0),
yOff: 117,
yOff: 79,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
@@ -352,27 +401,35 @@ func TestIndexPositionLines(t *testing.T) {
{
xOff: fixed.Int26_6(0),
yOff: 22,
glyphs: 20,
width: fixed.Int26_6(9836),
glyphs: 15,
width: fixed.Int26_6(7133),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 56,
glyphs: 19,
width: fixed.Int26_6(8801),
yOff: 41,
glyphs: 15,
width: fixed.Int26_6(7886),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 90,
glyphs: 13,
width: fixed.Int26_6(5852),
yOff: 60,
glyphs: 18,
width: fixed.Int26_6(8794),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(0),
yOff: 79,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
descent: fixed.Int26_6(216),
},
},
},
{
@@ -390,7 +447,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(2335),
yOff: 56,
yOff: 41,
glyphs: 15,
width: fixed.Int26_6(7905),
ascent: fixed.Int26_6(1407),
@@ -398,7 +455,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(1427),
yOff: 90,
yOff: 60,
glyphs: 18,
width: fixed.Int26_6(8813),
ascent: fixed.Int26_6(1407),
@@ -406,7 +463,7 @@ func TestIndexPositionLines(t *testing.T) {
},
{
xOff: fixed.Int26_6(8206),
yOff: 117,
yOff: 79,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
@@ -420,29 +477,37 @@ func TestIndexPositionLines(t *testing.T) {
glyphs: bidiRTLTextOpp,
expectedLines: []lineInfo{
{
xOff: fixed.Int26_6(404),
xOff: fixed.Int26_6(3107),
yOff: 22,
glyphs: 20,
width: fixed.Int26_6(9836),
glyphs: 15,
width: fixed.Int26_6(7133),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(1439),
yOff: 56,
glyphs: 19,
width: fixed.Int26_6(8801),
xOff: fixed.Int26_6(2354),
yOff: 41,
glyphs: 15,
width: fixed.Int26_6(7886),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(4388),
yOff: 90,
glyphs: 13,
width: fixed.Int26_6(5852),
xOff: fixed.Int26_6(1446),
yOff: 60,
glyphs: 18,
width: fixed.Int26_6(8794),
ascent: fixed.Int26_6(1407),
descent: fixed.Int26_6(756),
},
{
xOff: fixed.Int26_6(8206),
yOff: 79,
glyphs: 4,
width: fixed.Int26_6(2034),
ascent: fixed.Int26_6(968),
descent: fixed.Int26_6(216),
},
},
},
} {
@@ -503,13 +568,13 @@ func TestIndexPositionRunes(t *testing.T) {
{runes: 12, lineCol: screenPos{line: 1, col: 8}, runIndex: 1, towardOrigin: true},
{runes: 13, lineCol: screenPos{line: 1, col: 9}, runIndex: 1, towardOrigin: true},
{runes: 14, lineCol: screenPos{line: 1, col: 10}, runIndex: 1, towardOrigin: true},
{runes: 15, lineCol: screenPos{line: 1, col: 11}, runIndex: 1, towardOrigin: true},
{runes: 15, lineCol: screenPos{line: 1, col: 11}, runIndex: 2, towardOrigin: true},
{runes: 16, lineCol: screenPos{line: 1, col: 12}, runIndex: 2, towardOrigin: true},
{runes: 17, lineCol: screenPos{line: 1, col: 13}, runIndex: 2, towardOrigin: true},
{runes: 18, lineCol: screenPos{line: 2, col: 0}, runIndex: 0, towardOrigin: true},
{runes: 19, lineCol: screenPos{line: 2, col: 1}, runIndex: 0, towardOrigin: true},
{runes: 20, lineCol: screenPos{line: 2, col: 2}, runIndex: 0, towardOrigin: true},
{runes: 21, lineCol: screenPos{line: 2, col: 3}, runIndex: 0, towardOrigin: true},
{runes: 21, lineCol: screenPos{line: 2, col: 3}, runIndex: 1, towardOrigin: true},
{runes: 22, lineCol: screenPos{line: 2, col: 4}, runIndex: 1, towardOrigin: true},
{runes: 23, lineCol: screenPos{line: 2, col: 5}, runIndex: 1, towardOrigin: true},
{runes: 24, lineCol: screenPos{line: 2, col: 6}, runIndex: 1, towardOrigin: true},
@@ -521,7 +586,7 @@ func TestIndexPositionRunes(t *testing.T) {
{runes: 29, lineCol: screenPos{line: 3, col: 1}, runIndex: 0, towardOrigin: true},
{runes: 30, lineCol: screenPos{line: 3, col: 2}, runIndex: 0, towardOrigin: true},
{runes: 31, lineCol: screenPos{line: 3, col: 3}, runIndex: 0, towardOrigin: true},
{runes: 32, lineCol: screenPos{line: 3, col: 4}, runIndex: 0, towardOrigin: true},
{runes: 32, lineCol: screenPos{line: 3, col: 4}, runIndex: 1, towardOrigin: true},
{runes: 33, lineCol: screenPos{line: 3, col: 5}, runIndex: 1, towardOrigin: true},
{runes: 34, lineCol: screenPos{line: 3, col: 6}, runIndex: 1, towardOrigin: true},
{runes: 35, lineCol: screenPos{line: 4, col: 0}, runIndex: 0, towardOrigin: true},
+38 -14
View File
@@ -30,6 +30,12 @@ type Label struct {
Truncator string
// WrapPolicy configures how displayed text will be broken into lines.
WrapPolicy text.WrapPolicy
// LineHeight controls the distance between the baselines of lines of text.
// If zero, a sensible default will be used.
LineHeight unit.Sp
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
// sensible default will be used.
LineHeightScale float32
}
// Layout the label with the given shaper, font, size, text, and material.
@@ -49,16 +55,19 @@ type TextInfo struct {
func (l Label) LayoutDetailed(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, txt string, textMaterial op.CallOp) (layout.Dimensions, TextInfo) {
cs := gtx.Constraints
textSize := fixed.I(gtx.Sp(size))
lineHeight := fixed.I(gtx.Sp(l.LineHeight))
lt.LayoutString(text.Parameters{
Font: font,
PxPerEm: textSize,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
Alignment: l.Alignment,
WrapPolicy: l.WrapPolicy,
MaxWidth: cs.Max.X,
MinWidth: cs.Min.X,
Locale: gtx.Locale,
Font: font,
PxPerEm: textSize,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
Alignment: l.Alignment,
WrapPolicy: l.WrapPolicy,
MaxWidth: cs.Max.X,
MinWidth: cs.Min.X,
Locale: gtx.Locale,
LineHeight: lineHeight,
LineHeightScale: l.LineHeightScale,
}, txt)
m := op.Record(gtx.Ops)
viewport := image.Rectangle{Max: cs.Max}
@@ -125,27 +134,42 @@ type textIterator struct {
// processGlyph checks whether the glyph is visible within the iterator's configured
// viewport and (if so) updates the iterator's text dimensions to include the glyph.
func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visibleOrBefore bool) {
func (it *textIterator) processGlyph(g text.Glyph, ok bool) (visibleOrBefore bool) {
if it.maxLines > 0 {
if g.Flags&text.FlagTruncator != 0 && g.Flags&text.FlagClusterBreak != 0 {
// A glyph carrying both of these flags provides the count of truncated runes.
it.truncated = g.Runes
it.truncated = int(g.Runes)
}
if g.Flags&text.FlagLineBreak != 0 {
it.linesSeen++
}
if it.linesSeen == it.maxLines && g.Flags&text.FlagParagraphBreak != 0 {
return g, false
return false
}
}
// Compute the maximum extent to which glyphs overhang on the horizontal
// axis.
if d := g.Bounds.Min.X.Floor(); d < it.padding.Min.X {
// If the distance between the dot and the left edge of this glyph is
// less than the current padding, increase the left padding.
it.padding.Min.X = d
}
if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > it.padding.Max.X {
// If the distance between the dot and the right edge of this glyph
// minus the logical advance of this glyph is greater than the current
// padding, increase the right padding.
it.padding.Max.X = d
}
if d := (g.Bounds.Min.Y + g.Ascent).Floor(); d < it.padding.Min.Y {
// If the distance between the dot and the top of this glyph is greater
// than the ascent of the glyph, increase the top padding.
it.padding.Min.Y = d
}
if d := (g.Bounds.Max.Y - g.Descent).Ceil(); d > it.padding.Max.Y {
// If the distance between the dot and the bottom of this glyph is greater
// than the descent of the glyph, increase the bottom padding.
it.padding.Max.Y = d
}
logicalBounds := image.Rectangle{
Min: image.Pt(g.X.Floor(), int(g.Y)-g.Ascent.Ceil()),
Max: image.Pt((g.X + g.Advance).Ceil(), int(g.Y)+g.Descent.Ceil()),
@@ -167,7 +191,7 @@ func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visib
it.bounds.Max.X = max(it.bounds.Max.X, logicalBounds.Max.X)
it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y)
}
return g, ok && !below
return ok && !below
}
func fixedToFloat(i fixed.Int26_6) float32 {
@@ -182,7 +206,7 @@ func fixedToFloat(i fixed.Int26_6) float32 {
// This design is awkward, but prevents the line slice from escaping
// to the heap.
func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) {
_, visibleOrBefore := it.processGlyph(glyph, true)
visibleOrBefore := it.processGlyph(glyph, true)
if it.visible {
if len(line) == 0 {
it.lineOff = f32.Point{X: fixedToFloat(glyph.X), Y: float32(glyph.Y)}.Sub(layout.FPt(it.viewport.Min))
+65 -4
View File
@@ -146,10 +146,7 @@ func TestGlyphIterator(t *testing.T) {
glyphs := getGlyphs(16, 0, maxWidth, text.Start, tc.str)
it := textIterator{viewport: tc.viewport, maxLines: tc.maxLines}
for i, g := range glyphs {
gOut, ok := it.processGlyph(g, true)
if gOut != g {
t.Errorf("textIterator modified glyphs[%d], original:\n%#+v, modified:\n%#+v", i, g, gOut)
}
ok := it.processGlyph(g, true)
if !ok && i != tc.stopAtGlyph {
t.Errorf("expected iterator to stop at glyph %d, stopped at %d", tc.stopAtGlyph, i)
}
@@ -166,3 +163,67 @@ func TestGlyphIterator(t *testing.T) {
})
}
}
// TestGlyphIteratorPadding ensures that the glyph iterator computes correct padding
// around glyphs with unusual bounding boxes.
func TestGlyphIteratorPadding(t *testing.T) {
type testcase struct {
name string
glyph text.Glyph
viewport image.Rectangle
expectedDims image.Rectangle
expectedPadding image.Rectangle
expectedBaseline int
}
for _, tc := range []testcase{
{
name: "simple",
glyph: text.Glyph{
X: 0,
Y: 50,
Advance: fixed.I(50),
Ascent: fixed.I(50),
Descent: fixed.I(50),
Bounds: fixed.Rectangle26_6{
Min: fixed.Point26_6{
X: fixed.I(-5),
Y: fixed.I(-56),
},
Max: fixed.Point26_6{
X: fixed.I(57),
Y: fixed.I(58),
},
},
},
viewport: image.Rectangle{Max: image.Pt(math.MaxInt, math.MaxInt)},
expectedDims: image.Rectangle{
Max: image.Point{X: 50, Y: 100},
},
expectedBaseline: 50,
expectedPadding: image.Rectangle{
Min: image.Point{
X: -5,
Y: -6,
},
Max: image.Point{
X: 7,
Y: 8,
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
it := textIterator{viewport: tc.viewport}
it.processGlyph(tc.glyph, true)
if it.bounds != tc.expectedDims {
t.Errorf("expected bounds %#+v, got %#+v", tc.expectedDims, it.bounds)
}
if it.baseline != tc.expectedBaseline {
t.Errorf("expected baseline %d, got %d", tc.expectedBaseline, it.baseline)
}
if it.padding != tc.expectedPadding {
t.Errorf("expected padding %d, got %d", tc.expectedPadding, it.padding)
}
})
}
}
+5 -5
View File
@@ -61,8 +61,8 @@ func (s *Scrollbar) Layout(gtx layout.Context, axis layout.Axis, viewportStart,
}
// Jump to a click in the track.
for _, event := range s.track.Events(gtx) {
if event.Type != gesture.TypeClick ||
for _, event := range s.track.Update(gtx) {
if event.Kind != gesture.KindClick ||
event.Modifiers != key.Modifiers(0) ||
event.NumClicks > 1 {
continue
@@ -80,8 +80,8 @@ func (s *Scrollbar) Layout(gtx layout.Context, axis layout.Axis, viewportStart,
}
// Offset to account for any drags.
for _, event := range s.drag.Events(gtx.Metric, gtx, gesture.Axis(axis)) {
switch event.Type {
for _, event := range s.drag.Update(gtx.Metric, gtx, gesture.Axis(axis)) {
switch event.Kind {
case pointer.Drag:
case pointer.Release, pointer.Cancel:
s.dragging = false
@@ -136,7 +136,7 @@ func (s *Scrollbar) Layout(gtx layout.Context, axis layout.Axis, viewportStart,
// Process events from the indicator so that hover is
// detected properly.
_ = s.indicator.Events(gtx)
_ = s.indicator.Update(gtx)
return layout.Dimensions{}
}
+17 -19
View File
@@ -51,7 +51,7 @@ type IconButtonStyle struct {
}
func Button(th *Theme, button *widget.Clickable, txt string) ButtonStyle {
return ButtonStyle{
b := ButtonStyle{
Text: txt,
Color: th.Palette.ContrastFg,
CornerRadius: 4,
@@ -64,6 +64,8 @@ func Button(th *Theme, button *widget.Clickable, txt string) ButtonStyle {
Button: button,
shaper: th.Shaper,
}
b.Font.Typeface = th.Face
return b
}
func ButtonLayout(th *Theme, button *widget.Clickable) ButtonLayoutStyle {
@@ -91,9 +93,8 @@ func IconButton(th *Theme, button *widget.Clickable, icon *widget.Icon, descript
func Clickable(gtx layout.Context, button *widget.Clickable, w layout.Widget) layout.Dimensions {
return button.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
constraints := gtx.Constraints
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
if button.Hovered() || button.Focused() {
paint.Fill(gtx.Ops, f32color.Hovered(color.NRGBA{}))
@@ -102,11 +103,8 @@ func Clickable(gtx layout.Context, button *widget.Clickable, w layout.Widget) la
drawInk(gtx, c)
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
gtx.Constraints = constraints
return w(gtx)
}),
},
w,
)
})
}
@@ -129,8 +127,8 @@ func (b ButtonLayoutStyle) Layout(gtx layout.Context, w layout.Widget) layout.Di
min := gtx.Constraints.Min
return b.Button.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rr := gtx.Dp(b.CornerRadius)
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
background := b.Background
@@ -145,11 +143,11 @@ func (b ButtonLayoutStyle) Layout(gtx layout.Context, w layout.Widget) layout.Di
drawInk(gtx, c)
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
},
func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min = min
return layout.Center.Layout(gtx, w)
}),
},
)
})
}
@@ -161,8 +159,8 @@ func (b IconButtonStyle) Layout(gtx layout.Context) layout.Dimensions {
if d := b.Description; d != "" {
semantic.DescriptionOp(b.Description).Add(gtx.Ops)
}
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
rr := (gtx.Constraints.Min.X + gtx.Constraints.Min.Y) / 4
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
background := b.Background
@@ -177,8 +175,8 @@ func (b IconButtonStyle) Layout(gtx layout.Context) layout.Dimensions {
drawInk(gtx, c)
}
return layout.Dimensions{Size: gtx.Constraints.Min}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
},
func(gtx layout.Context) layout.Dimensions {
return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
size := gtx.Dp(b.Size)
if b.Icon != nil {
@@ -189,7 +187,7 @@ func (b IconButtonStyle) Layout(gtx layout.Context) layout.Dimensions {
Size: image.Point{X: size, Y: size},
}
})
}),
},
)
})
c := m.Stop()
+3 -1
View File
@@ -14,7 +14,7 @@ type CheckBoxStyle struct {
}
func CheckBox(th *Theme, checkBox *widget.Bool, label string) CheckBoxStyle {
return CheckBoxStyle{
c := CheckBoxStyle{
CheckBox: checkBox,
checkable: checkable{
Label: label,
@@ -27,6 +27,8 @@ func CheckBox(th *Theme, checkBox *widget.Bool, label string) CheckBoxStyle {
uncheckedStateIcon: th.Icon.CheckBoxUnchecked,
},
}
c.checkable.Font.Typeface = th.Face
return c
}
// Layout updates the checkBox and displays it.
+5 -5
View File
@@ -88,18 +88,18 @@ func (d DecorationsStyle) layoutDecorations(gtx layout.Context) layout.Dimension
cl := d.Decorations.Clickable(a)
dims := cl.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
semantic.Button.Add(gtx.Ops)
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
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}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
},
func(gtx layout.Context) layout.Dimensions {
paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
return inset.Layout(gtx, w)
}),
},
)
})
size.X += dims.Size.X
+20 -4
View File
@@ -16,8 +16,14 @@ import (
)
type EditorStyle struct {
Font font.Font
TextSize unit.Sp
Font font.Font
// LineHeight controls the distance between the baselines of lines of text.
// If zero, a sensible default will be used.
LineHeight unit.Sp
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
// sensible default will be used.
LineHeightScale float32
TextSize unit.Sp
// Color is the text color.
Color color.NRGBA
// Hint contains the text displayed when the editor is empty.
@@ -33,7 +39,10 @@ type EditorStyle struct {
func Editor(th *Theme, editor *widget.Editor, hint string) EditorStyle {
return EditorStyle{
Editor: editor,
Editor: editor,
Font: font.Font{
Typeface: th.Face,
},
TextSize: th.TextSize,
Color: th.Palette.Fg,
shaper: th.Shaper,
@@ -61,7 +70,12 @@ func (e EditorStyle) Layout(gtx layout.Context) layout.Dimensions {
}
macro := op.Record(gtx.Ops)
tl := widget.Label{Alignment: e.Editor.Alignment, MaxLines: maxlines}
tl := widget.Label{
Alignment: e.Editor.Alignment,
MaxLines: maxlines,
LineHeight: e.LineHeight,
LineHeightScale: e.LineHeightScale,
}
dims := tl.Layout(gtx, e.shaper, e.Font, e.TextSize, e.Hint, hintColor)
call := macro.Stop()
@@ -71,6 +85,8 @@ func (e EditorStyle) Layout(gtx layout.Context) layout.Dimensions {
if h := dims.Size.Y; gtx.Constraints.Min.Y < h {
gtx.Constraints.Min.Y = h
}
e.Editor.LineHeight = e.LineHeight
e.Editor.LineHeightScale = e.LineHeightScale
dims = e.Editor.Layout(gtx, e.shaper, e.Font, e.TextSize, textColor, selectionColor)
if e.Editor.Len() == 0 {
call.Add(gtx.Ops)
+17 -5
View File
@@ -38,6 +38,12 @@ type LabelStyle struct {
Text string
// TextSize determines the size of the text glyphs.
TextSize unit.Sp
// LineHeight controls the distance between the baselines of lines of text.
// If zero, a sensible default will be used.
LineHeight unit.Sp
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
// sensible default will be used.
LineHeightScale float32
// Shaper is the text shaper used to display this labe. This field is automatically
// set using by all constructor functions. If constructing a LabelStyle literal, you
@@ -105,13 +111,15 @@ func Overline(th *Theme, txt string) LabelStyle {
}
func Label(th *Theme, size unit.Sp, txt string) LabelStyle {
return LabelStyle{
l := LabelStyle{
Text: txt,
Color: th.Palette.Fg,
SelectionColor: f32color.MulAlpha(th.Palette.ContrastBg, 0x60),
TextSize: size,
Shaper: th.Shaper,
}
l.Font.Typeface = th.Face
return l
}
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
@@ -130,13 +138,17 @@ func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
l.State.MaxLines = l.MaxLines
l.State.Truncator = l.Truncator
l.State.WrapPolicy = l.WrapPolicy
l.State.LineHeight = l.LineHeight
l.State.LineHeightScale = l.LineHeightScale
return l.State.Layout(gtx, l.Shaper, l.Font, l.TextSize, textColor, selectColor)
}
tl := widget.Label{
Alignment: l.Alignment,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
WrapPolicy: l.WrapPolicy,
Alignment: l.Alignment,
MaxLines: l.MaxLines,
Truncator: l.Truncator,
WrapPolicy: l.WrapPolicy,
LineHeight: l.LineHeight,
LineHeightScale: l.LineHeightScale,
}
return tl.Layout(gtx, l.Shaper, l.Font, l.TextSize, l.Text, textColor)
}
+6 -10
View File
@@ -165,12 +165,9 @@ func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportSta
if axis == layout.Horizontal {
inset.Top, inset.Bottom, inset.Left, inset.Right = inset.Left, inset.Right, inset.Top, inset.Bottom
}
// Capture the outer constraints because layout.Stack will reset
// the minimum to zero.
outerConstraints := gtx.Constraints
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
return layout.Background{}.Layout(gtx,
func(gtx layout.Context) layout.Dimensions {
// Lay out the draggable track underneath the scroll indicator.
area := image.Rectangle{
Max: gtx.Constraints.Min,
@@ -186,10 +183,9 @@ func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportSta
s.Scrollbar.AddTrack(gtx.Ops)
paint.FillShape(gtx.Ops, s.Track.Color, clip.Rect(area).Op())
return layout.Dimensions{}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
gtx.Constraints = outerConstraints
return layout.Dimensions{Size: gtx.Constraints.Min}
},
func(gtx layout.Context) layout.Dimensions {
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
// Use axis-independent constraints.
gtx.Constraints.Min = axis.Convert(gtx.Constraints.Min)
@@ -231,7 +227,7 @@ func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportSta
return layout.Dimensions{Size: axis.Convert(gtx.Constraints.Min)}
})
}),
},
)
}

Some files were not shown because too many files have changed in this diff Show More