Compare commits

...

49 Commits

Author SHA1 Message Date
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
78 changed files with 2328 additions and 863 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ environment:
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
+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 -1
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 .)"
+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: |
+3 -3
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(
@@ -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()
}
}
+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
}
+19 -14
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)
@@ -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
}
+18 -11
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.
@@ -128,18 +131,18 @@ func NewDisplayLink(callback func()) (*displayLink, error) {
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()
}
}
+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]];
+2
View File
@@ -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"
}
+8 -6
View File
@@ -365,11 +365,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,6 +523,8 @@ 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
switch cdir {
@@ -788,13 +790,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
+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 -1
View File
@@ -94,7 +94,10 @@ type wlDisplay struct {
// Notification pipe fds.
notify struct {
read, write int
read int
mu sync.Mutex
write int
}
repeat repeatState
@@ -1442,6 +1445,11 @@ func (w *window) SetAnimating(anim bool) {
// Wakeup wakes up the event loop through the notification pipe.
func (d *wlDisplay) wakeup() {
oneByte := make([]byte, 1)
d.notify.mu.Lock()
defer d.notify.mu.Unlock()
if d.notify.write == 0 {
return
}
if _, err := syscall.Write(d.notify.write, oneByte); err != nil && err != syscall.EAGAIN {
panic(fmt.Errorf("failed to write to pipe: %v", err))
}
@@ -1820,10 +1828,12 @@ func newWLDisplay() (*wlDisplay, error) {
}
func (d *wlDisplay) destroy() {
d.notify.mu.Lock()
if d.notify.write != 0 {
syscall.Close(d.notify.write)
d.notify.write = 0
}
d.notify.mu.Unlock()
if d.notify.read != 0 {
syscall.Close(d.notify.read)
d.notify.read = 0
+63 -50
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),
@@ -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) {
@@ -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,28 +695,30 @@ 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:
mi := windows.GetMonitorInfo(w.hwnd)
@@ -718,7 +731,7 @@ func (w *window) Configure(options []Option) {
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) {
+5 -1
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"
@@ -136,9 +138,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),
+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
}
}
+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=
+194 -43
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,10 +177,12 @@ 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
}
@@ -222,8 +247,6 @@ func decodeLinearGradientOp(data []byte) linearGradientOpData {
}
}
type clipType uint8
type resource interface {
release()
}
@@ -273,6 +296,9 @@ type blitUniforms struct {
transform [4]float32
uvTransformR1 [4]float32
uvTransformR2 [4]float32
opacity float32
fbo float32
_ [2]float32
}
type colorUniforms struct {
@@ -284,7 +310,7 @@ type gradientUniforms struct {
color2 f32color.RGBA
}
type materialType uint8
type clipType uint8
const (
clipTypeNone clipType = iota
@@ -292,6 +318,8 @@ const (
clipTypeIntersection
)
type materialType uint8
const (
materialColor materialType = iota
materialLinearGradient
@@ -391,6 +419,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 +430,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()
@@ -464,15 +494,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 +780,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 +792,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 +877,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 +977,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 +1090,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 +1103,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 +1141,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 +1183,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 +1197,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 +1213,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 +1251,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 +1265,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: 334 B

+16
View File
@@ -413,6 +413,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
}
+48 -5
View File
@@ -19,6 +19,17 @@ type Ops struct {
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
@@ -40,9 +51,10 @@ const (
TypeMacro OpType = iota + firstOpIndex
TypeCall
TypeDefer
TypePushTransform
TypeTransform
TypePopTransform
TypePushOpacity
TypePopOpacity
TypeInvalidate
TypeImage
TypePaint
@@ -111,6 +123,7 @@ const (
ClipStack StackKind = iota
TransStack
PassStack
OpacityStack
_StackKind
)
@@ -124,9 +137,10 @@ 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
TypePaintLen = 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++
}
@@ -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)
@@ -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},
@@ -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:
+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))
+37 -23
View File
@@ -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
}
+49
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"
@@ -221,6 +222,43 @@ func TestPointerTypes(t *testing.T) {
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)
@@ -1231,6 +1269,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
+4 -4
View File
@@ -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])
+2 -2
View File
@@ -40,12 +40,12 @@ type SelectedOp bool
type DisabledOp 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)
}
+6
View File
@@ -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))
+36
View File
@@ -44,6 +44,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 int
ops *ops.Ops
}
// NewImageOp creates an ImageOp backed by src.
//
// NewImageOp assumes the backing image is immutable, and may cache a
@@ -145,3 +153,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)
}
})
}
})
}
}
+281 -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,60 @@ 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
}
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 +371,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 +398,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 +486,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 +557,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 +650,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 +714,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 +730,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 +765,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 +864,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 +872,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 +894,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 +902,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 +964,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 {
+2
View File
@@ -160,6 +160,8 @@ type layoutKey struct {
font giofont.Font
forceTruncate bool
wrapPolicy WrapPolicy
lineHeight fixed.Int26_6
lineHeightScale float32
}
type pathKey struct {
+76 -24
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.
@@ -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
}
@@ -457,6 +504,9 @@ func (l *Shaper) NextGlyph() (_ Glyph, ok bool) {
}
if endOfCluster {
glyph.Flags |= FlagClusterBreak
if run.truncator {
glyph.Runes += 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 {
+104 -23
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),
@@ -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),
@@ -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 += g.Runes
} else {
truncated += 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{}
@@ -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)
+19 -5
View File
@@ -28,6 +28,7 @@ type Clickable struct {
keyTag struct{}
requestFocus bool
focused bool
pressedKey string
}
// Click represents a click.
@@ -178,17 +179,30 @@ 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 = ""
b.clicks = append(b.clicks, Click{
Modifiers: e.Modifiers,
NumClicks: 1,
})
}
}
}
}
+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
r.Queue(
key.Event{
Name: key.NameReturn,
State: key.Press,
},
key.Event{
Name: key.NameReturn,
State: key.Release,
},
)
frame()
if !b1.Clicked() {
t.Error("button 1 did not get clicked when it got return press & release")
}
if b2.Clicked() {
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() {
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() {
t.Error("button 1 got clicked, even if it had lost focus")
}
if b2.Clicked() {
t.Error("button 2 should not have been clicked, as it only got return release")
}
}
+8
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.
@@ -504,6 +510,8 @@ 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
+21 -20
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)
@@ -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{}
@@ -349,7 +349,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 +419,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 +508,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 +541,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 +588,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,7 +680,7 @@ func TestEditorMoveWord(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{}
e.SetText(t)
@@ -784,7 +785,7 @@ func TestEditorInsert(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{}
e.SetText(t)
@@ -874,7 +875,7 @@ func TestEditorDeleteWord(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{}
e.SetText(t)
@@ -928,7 +929,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)
@@ -1026,7 +1027,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 +1115,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 +1146,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 +1170,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{})
+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},
+33 -9
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}
@@ -141,11 +150,26 @@ func (it *textIterator) processGlyph(g text.Glyph, ok bool) (_ text.Glyph, visib
// 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()),
+64
View File
@@ -166,3 +166,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)
}
})
}
}
+3 -1
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 {
+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.
+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)
}
+1 -2
View File
@@ -5,7 +5,6 @@ import (
"testing"
"time"
"gioui.org/font/gofont"
"gioui.org/io/system"
"gioui.org/layout"
"gioui.org/op"
@@ -45,7 +44,7 @@ func TestListAnchorStrategies(t *testing.T) {
var list widget.List
list.Axis = layout.Vertical
elements := 100
th := material.NewTheme(gofont.Collection())
th := material.NewTheme()
materialList := material.List(th, &list)
indicatorWidth := gtx.Dp(materialList.Width())
+3 -1
View File
@@ -17,7 +17,7 @@ type RadioButtonStyle struct {
// RadioButton returns a RadioButton with a label. The key specifies
// the value for the Enum.
func RadioButton(th *Theme, group *widget.Enum, key, label string) RadioButtonStyle {
return RadioButtonStyle{
r := RadioButtonStyle{
Group: group,
checkable: checkable{
Label: label,
@@ -32,6 +32,8 @@ func RadioButton(th *Theme, group *widget.Enum, key, label string) RadioButtonSt
},
Key: key,
}
r.checkable.Font.Typeface = th.Face
return r
}
// Layout updates enum and displays the radio button.
+5 -4
View File
@@ -42,15 +42,16 @@ type Theme struct {
RadioChecked *widget.Icon
RadioUnchecked *widget.Icon
}
// Face selects the default typeface for text.
Face font.Typeface
// FingerSize is the minimum touch target size.
FingerSize unit.Dp
}
func NewTheme(fontCollection []font.FontFace) *Theme {
t := &Theme{
Shaper: text.NewShaper(fontCollection),
}
// NewTheme constructs a theme (and underlying text shaper).
func NewTheme() *Theme {
t := &Theme{Shaper: &text.Shaper{}}
t.Palette = Palette{
Fg: rgb(0x000000),
Bg: rgb(0xffffff),
+11 -3
View File
@@ -59,9 +59,15 @@ type Selectable struct {
// if text was cut off. Defaults to "…" if left empty.
Truncator string
// WrapPolicy configures how displayed text will be broken into lines.
WrapPolicy text.WrapPolicy
initialized bool
source stringSource
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
initialized bool
source stringSource
// scratch is a buffer reused to efficiently read text out of the
// textView.
scratch []byte
@@ -181,6 +187,8 @@ func (l *Selectable) Truncated() bool {
// paint material for the text and selection rectangles, respectively.
func (l *Selectable) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectionMaterial op.CallOp) layout.Dimensions {
l.initialize()
l.text.LineHeight = l.LineHeight
l.text.LineHeightScale = l.LineHeightScale
l.text.Alignment = l.Alignment
l.text.MaxLines = l.MaxLines
l.text.Truncator = l.Truncator
+2 -2
View File
@@ -37,7 +37,7 @@ func TestSelectableMove(t *testing.T) {
Ops: new(op.Ops),
Locale: english,
}
cache := text.NewShaper(gofont.Collection())
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection()))
fnt := font.Font{}
fontSize := unit.Sp(10)
@@ -82,7 +82,7 @@ func TestSelectableConfigurations(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"
+14
View File
@@ -44,6 +44,12 @@ type textSource interface {
// be scrolled, and for configuring and drawing text selection boxes.
type textView struct {
Alignment text.Alignment
// 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
// SingleLine forces the text to stay on a single line.
// SingleLine also sets the scrolling direction to
// horizontal.
@@ -273,6 +279,14 @@ func (e *textView) Update(gtx layout.Context, lt *text.Shaper, font font.Font, s
e.params.WrapPolicy = e.WrapPolicy
e.invalidate()
}
if lh := fixed.I(gtx.Sp(e.LineHeight)); lh != e.params.LineHeight {
e.params.LineHeight = lh
e.invalidate()
}
if e.LineHeightScale != e.params.LineHeightScale {
e.params.LineHeightScale = e.LineHeightScale
e.invalidate()
}
e.makeValid()
if eventHandling != nil {
+5 -5
View File
@@ -86,7 +86,7 @@ func BenchmarkLabelStatic(b *testing.B) {
},
Locale: locale,
}
cache := text.NewShaper(benchFonts)
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(benchFonts))
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
@@ -118,7 +118,7 @@ func BenchmarkLabelDynamic(b *testing.B) {
},
Locale: locale,
}
cache := text.NewShaper(benchFonts)
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(benchFonts))
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
@@ -153,7 +153,7 @@ func BenchmarkEditorStatic(b *testing.B) {
},
Locale: locale,
}
cache := text.NewShaper(benchFonts)
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(benchFonts))
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
@@ -186,7 +186,7 @@ func BenchmarkEditorDynamic(b *testing.B) {
},
Locale: locale,
}
cache := text.NewShaper(benchFonts)
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(benchFonts))
if render {
win, _ = headless.NewWindow(size.X, size.Y)
defer win.Release()
@@ -224,7 +224,7 @@ func FuzzEditorEditing(f *testing.F) {
},
Locale: arabic,
}
cache := text.NewShaper(benchFonts)
cache := text.NewShaper(text.NoSystemFonts(), text.WithCollection(benchFonts))
fontSize := unit.Sp(10)
font := font.Font{}
e := Editor{}