This commit updates the logic that computes scroll viewport coordinates to correctly
consume layout.Position.OffsetLast, which was previously ignored. The impact of ignoring
that field was that dragging on a scroll indicator could sometimes fail to reach the
end of the list.
I've updated the logic to consume that field, which increased the amount of visual
jitter in the position of the scrollbar. I then also added a mechanism for smoothing
the jitter by using both methods of deriving the viewport and synthesizing a viewport
from both.
This new strategy exhibits a lower standard deviation than the other options on each of:
- the length of the scroll indicator
- the change in the start coordinate of the viewport when scrolling smoothly
- the change in the end coordinate of the viewport when scrolling smoothly
Fixes: https://todo.sr.ht/~eliasnaur/gio/504
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
There are many times when an application wants to know metadata about shaped text without
allocating a stateful text widget such as widget.Selectable. This commit introduces widget.TextInfo
and adds an extra LayoutDetailed method to widget.Label returning this struct. Currently
the struct only provides the information necessary to determine whether the text was truncated
(useful for deciding whether a tooltip makes sense), but it can be expanded to include text metrics
in the future for applications which require those.
In the future other text widgets may surface methods of acquiring widget.TextInfos, but the critical
gap in the API is that we can't currently determine whether a stateless label was truncated, so
I'm starting here.
I considered making Label.Layout() always return this, but I didn't want to introduce a breaking
API change yet. I have some other thoughts I want to explore about the label API which might
trigger breaking changes (moving parameters into fields).
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit enables consumers of the text shaper to select a policy for how
line breaking candidates will be chosen. The new default policy can break lines
within "words" (UAX#14 segments) when words do not fit by themselves on a line.
This ensures that text does not horizontally overflow its bounding box unless
the available width is insufficient to display a single UAX#29 grapheme cluster.
Fixes: https://todo.sr.ht/~eliasnaur/gio/467
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
The previous docs claimed that failing to set a textMaterial would result in
invisible glyphs when in reality it results in using whatever the current paint
material is. This could be the paint material from before laying out the glyphs,
or it could be the material for a bitmap glyph. As such, it's better to say that
the color is undefined.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
Egon pointed out that the current default is unusable on touch screens in Slack, so this
change should hopefully ensure the indicator is interactable on touch devices.
I considered expanding the minor axis dimensions as well, but I don't know what value to
use. The 38DP default would be enormous on non-mobile displays if we made that the default
width.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds back support for loading font collections, which we
lost when switching to the harfbuzz-based shaper last January. In
addition, this commit takes advantage of our new font loading library's
metadata facilities to automatically construct text.FontFaces for all
fonts within a collection. This is significantly more ergonomic for
users, and can be used to load single fonts with automatic metadata
detection as well.
I've exposed a opentype.Face.Font() method that can be used to get the
font metadata for a given face as well, though you have to type assert to
see it:
var myFace text.Face
if asOpentype, ok := myFace.(opentype.Face); ok {
myFont := asOpentype.Font()
}
The one problem with this approach is that the font variant field always
be automatically populated. Mono font detection is supported, but
other variants like SmallCaps are more complicated and may need to be
expressed differently in the future (smallcaps is a feature that any font
file can have, not necessarily a separate font file). See this [0] upstream
issue for details.
Additionally, in order to avoid import cycles, I've moved the declarations
of font attributes to package font. You can fix your code automatically to
refer to the new definitions by running the following:
gofmt -w -r 'text.FontFace -> font.FontFace' .
gofmt -w -r 'text.Variant -> font.Variant' .
gofmt -w -r 'text.Style -> font.Style' .
gofmt -w -r 'text.Typeface -> font.Typeface' .
gofmt -w -r 'text.Font -> font.Font' .
gofmt -w -r 'text.Regular -> font.Regular' .
gofmt -w -r 'text.Italic -> font.Italic' .
gofmt -w -r 'text.Thin -> font.Thin' .
gofmt -w -r 'text.ExtraLight -> font.ExtraLight' .
gofmt -w -r 'text.Light -> font.Light' .
gofmt -w -r 'text.Normal -> font.Normal' .
gofmt -w -r 'text.Medium -> font.Medium' .
gofmt -w -r 'text.SemiBold -> font.SemiBold' .
gofmt -w -r 'text.Bold -> font.Bold' .
gofmt -w -r 'text.ExtraBold -> font.ExtraBold' .
gofmt -w -r 'text.Black -> font.Black' .
gofmt -w -r 'text.Hairline -> font.Thin' .
gofmt -w -r 'text.UltraLight -> font.ExtraLight' .
gofmt -w -r 'text.DemiBold -> font.SemiBold' .
gofmt -w -r 'text.UltraBold -> font.ExtraBold' .
gofmt -w -r 'text.Heavy -> font.Black' .
gofmt -w -r 'text.ExtraBlack -> font.Black+50' .
gofmt -w -r 'text.UltraBlack -> font.ExtraBlack' .
Make sure each affected file imports gioui.org/font.
[0] https://github.com/go-text/typesetting/issues/57
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit combs through the logic of computing glyph sizes and positions,
attempting to remove all unnecessary rounding and truncation. This is in
an effort to help text display consistently when different-length strings
are displayed near one another.
The specific problem prompting this change was end-aligned text stacked in
rows with a common suffix. If the rows displayed different values, they
would shape such that those final glyphs were at different fractional x
coordinates, and then they would be aligned with rounding that could display
them at different x positions in spite of the fact that both suffixes are
the same glyphs.
By removing rounding from Alignment.Align, the largest problem is fixed, but
I'm also removing other unnecessary loss of precision that can circumstantially
contribute to this sort of visual issue.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
We panic when someone constructs a literal LabelStyle because they cannot possibly
populate the shaper field. The resulting error is cryptic, and unusual within Gio
because most style types are safe to construct literally. This commit enables
creating literal LabelStyles by exporting the Shaper field, and also documents
the purposes of all of the fields.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit moves the min/max width of shaped text and the text's Locale into
text.Parameters. They were previously passed as separate function parameters to
the shaper, but this made little sense and added visual noise. This is a breaking
change, but only if you previously invoked the shaping API directly.
Callers of text.(*Shaper).LayoutString should change:
shaper.LayoutString(params, minWidth, maxWidth, locale, "string")
to
params.MinWidth=minWidth
params.MaxWidth=maxWidth
params.Locale=locale
shaper.LayoutString(params, "string")
Callers of text.(*Shaper).Layout should do likewise.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit separates the types for interactive and non-interactive text within
package widget. widget.Selectable is used for all interactive text. widget.Label
is used for all non-interactive text. There is no longer a field on widget.Label
to provide it with a Selectable. If you want selectable text and are not relying
upon the material pacakge API, you need to create widget.Selectables instead of
widget.Labels. The material package's LabelStyle API is unchanged.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds an exported method to enable widgets to detect
when the text displayed by a Selectable has been truncated. This
can be used to implement proper show-full-text-in-an-overlay
behavior in a parent widget. I haven't attempted to implement
that in core yet, as it is a complex feature involving animation
and pointer interaction.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds support for the idea of a text "Truncator", a string
that is shown at the end of truncated text to indicate that it has been
shortened because it would not fit within the requested number of lines.
When specifying a maximum number of lines, a truncator symbol is always
used. If the user does not provide one, the rune `…` is used. This
requirement results in a better user experience and significantly simpler
code, as we can rely upon the presence of one or more truncator glyphs in
the output glyph stream when truncation has occurred.
When interacting with truncated text, the truncator glyphs all act as
a single, indivisible unit. They can be selected or not, and if selected
they act as the entire contents of the truncated portion of the text.
This means that copying all of a truncated label will copy the entire
label text content, with the truncator symbol not appearing at all.
Concretely, the exposed text API now accepts a Truncator string in
text.Parameters, and there is a new glyph flag FlagTruncator which indicates
that the glyph is part of the truncator run. The truncator run will only
have a single FlagClusterBreak (even if the run would usually have many),
and the glyph with both FlagClusterBreak and FlagTruncator will have the
quantity of truncated runes in its Runes field. This necessitated increasing
the size of the Runes field from a byte to an int, as it's theoretically possible
for quite a lot of text to be truncated.
This commit necessarily bumps our go-text/typesetting dependency to the version
exposing truncation in the exported API.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit teaches the text widgets how to position their cursor according to
grapheme cluster boundaries rather than rune boundaries. While this is more work,
the results better match the expectations of users. A "grapheme cluster" is a
user-perceived character that may be composed of arbitrarily many runes.
I chose to implement this within widgets for two reasons:
- grapheme cluster boundaries would be extremely difficult to encode within the
glyph stream returned by the text shaper
- not all text needs to be segmented, only text that can be interacted with
All mutation operations exposed by widget.Editor now work in terms of grapheme
clusters instead of runes.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit allows the glyph index type to be reset and reused, preventing the
reallocation of numerous buffers when indexing glyphs.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit upgrades our version of eliasnaur.com/font to include a color
emoji font and uses that to benchmark displaying large quantities of emoji.
As expected, this is very slow when the strings change frequently, and uses
silly amounts of memory. Future commits will work to improve this.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit supports rendering opentype glyphs containing bitmap data instead of
color data. In order to support returning the shaped bitmap glyphs from the Shaper's
Shape() method, it has gained a second return parameter, an op.CallOp. Adding
that CallOp immediately after or immediately before painting the returned path
will display the bitmap glyphs.
The consequences of supporting colored glyphs forced changes upon the widget APIs
for widgets that display text. Previously text always had a fixed paint material,
so we could rely upon the caller setting the material (e.g. adding a paint.ColorOp)
before painting the glyphs and everything would work. Now that we display image-
based glyphs, we end up changing the painting material to an image midway through
displaying text. This is an awkward consequence of how we currently manage the
painting material, and to work around it widgets now accept an op.CallOp that
is expected to set the proper paint material. Text widgets will use that op.CallOp
before painting text (or other paint operations) to ensure that they are painting
with the proper materials.
This, in turn, changed the APIs for laying out widget.Editor, widget.Label, and
widget.Selectable, and eliminated the need for them to accept a callback (the
callback was only really to set the colors). Dropping that callback function
allowed me to consolidate widget.Label to only need one exported Layout method,
and allowed me to unexport the PaintText, PaintCaret, and PaintSelection methods
from widget.Editor and widget.Selectable. Those methods are useless in the public
API now that they don't need to be invoked after applying a color operation.
Callers of the raw text shaper API will need to make the following changes:
- Where before you used:
var ops *op.Ops // Assume we have an operation list.
var shaper *text.Shaper // Assume we have a shaper.
var col color.NRGBA // Assume we have a text color.
var glyphs []text.Glyph // Assume we have already filled a slice of glyphs.
shape := shaper.Shape(glyphs)
paint.FillShape(ops, col, clip.Outline{Path:shape}.Op())
- Now you should do:
shape, call := shaper.Shape(glyphs)
paint.FillShape(ops, col, clip.Outline{Path:shape}.Op())
call.Add(ops)
Callers of the widget.{Label,Selectable,Editor} APIs will need to make the
following changes:
- Where before you used:
var gtx layout.Context // Assume we have an operation list.
var shaper *text.Shaper // Assume we have a shaper.
var textCol color.NRGBA // Assume we have a text color.
var selectCol color.NRGBA // Assume we have a selection color.
var ed widget.Editor // Assume we have an editor.
var sel widget.Selectable // Assume we have a selectable.
// Lay out an editor.
ed.Layout(gtx, shaper, text.Font{}, unit.Sp(30), func(layout.Context) layout.Dimensions {
// Paint the editor.
})
// Lay out a selectable.
sel.Layout(gtx, shaper, text.Font{}, unit.Sp(30), func(layout.Context) layout.Dimensions {
// Paint the selectable.
})
// Lay out an interactive label.
widget.Label{}.LayoutSelectable(gtx, shaper, text.Font{}, unit.Sp(30), "hello", func(layout.Context) layout.Dimensions {
// Paint the label.
})
// Lay out a non-interactive label.
widget.Label{}.Layout(gtx, shaper, text.Font{}, unit.Sp(30), "hello")
- Now you should do:
// Capture setting the text paint material in a macro.
textColMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: textCol}.Add(gtx.Ops)
textMaterial := textColMacro.Stop()
// Capture setting the selection paint material in a macro.
selectColMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: selectCol}.Add(gtx.Ops)
selectMaterial := selectColMacro.Stop()
// Lay out an editor.
ed.Layout(gtx, shaper, text.Font{}, unit.Sp(30), textMaterial, selectMaterial)
// Lay out a selectable.
sel.Layout(gtx, shaper, text.Font{}, unit.Sp(30), textMaterial, selectMaterial)
// Lay out a label (no difference between interactive and non-interactive)
widget.Label{}.Layout(gtx, shaper, text.Font{}, unit.Sp(30), "hello", textMaterial, selectMaterial)
Callers of the material package API do not need to make any changes.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
When no scale factor is set, scale by 1.0, mapping one image pixel to
one device-independent pixel. This matches the behavior of CSS and other
frameworks.
The old code attempted to convert to Dp while taking the image's DPI
into account. This was wrong in two ways:
- It assumed that the default display DPI is 160, but this is only true
for Android. Other platforms use 96, 162, or leave it undefined. Thus
image.Layout's idea of a dp didn't match that of Gio on most
platforms.
- It tried to account for image DPI, and assumed a default of 72. This
was wrong in that DPI in images is merely metadata meant for printing,
not display. The vast majority of software such as image viewers and
image editors do not take DPI into account, mapping one image pixel
either to one physical pixel or to one device-independent pixel. That
is, users would expect their images to either display 1 to 1, or scaled
based on PxPerDp, but not scaled based on the image's DPI.
We default to a scale of 1 to stay consistent with other parts of Gio
that scale by default. Users who don't want any scaling can continue to
set the scale to the inverse of PxPerDp.
While we're here we clarify the documentation of the Scale field.
This change is backwards incompatible for users that relied on the
default scale.
Signed-off-by: Dominik Honnef <dominik@honnef.co>
This commit extends the key event handling for text widgets to always check for
appropriate modifier keys. Previously this wasn't necessary, as the text widgets
would only ever receive key events it registered for, but now it may be the top-level
key event handler and thus receive all key events that aren't handled elsewhere.
Fixes: https://todo.sr.ht/~eliasnaur/gio/487
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
Setting Float.Invert=true not only inverts the order of values (which was already easily done by swapping min and max), it also draws the widget inverted so that the track is darkened on the opposite side from usual.
This patch also fixes a bug wherein a vertical slider was drawn inverted by default.
Signed-off-by: Gordon Klaus <gordon.klaus@gmail.com>
This commit alters the textView API to give callers the option to provide
their own buffers for reading text. This enables some widget usecases to
be zero-allocation if a widget simply needs to examine the contents of the
text without returning it as a string.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit updates the textView to better describe the expectations
and behaviors of the Update and Paint* methods.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
go.mod specifies 1.18, due to go.mod behavior and to avoid some issues
with updating the dependencies. However, we can still support older go
version, as long as it compiles with the older version.
Signed-off-by: Egon Elbre <egonelbre@gmail.com>
The io.Reader based API has the potential to be significantly more
efficient, and there are very few users of the runereader API. This
commit simply drops it entirely in favor of the reader API.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds exported methods to both LabelState and Editor
allowing callers to locate the text regions representing a range
of runes. This can be used to build interactive subregions of text,
like (for instance) hyperlinks.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit rebuilds the editor and label types on the common
foundation provided by textView. This enables labels to have
optional state that makes them selectable, and allows the
two widgets to share the code for managing cursor positions,
displaying selections, and soforth. Labels now have an additional
Layout function which can be invoked if they have a Selectable.
It accepts a layout.Widget used to paint their contents. Stateless
labels should still use the old Layout method.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds a standalone state type for manipulating
and displaying text. It reads text from a minimal interface,
shapes it, tracks valid cursor positions, and provides sizing
and scrolling services to higher-level widgets. My long term
goal with these types is to export them to allow non-core widgets
to build atop them, but I've left them private for now.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit provides a new ReadOnly boolean on the editor. If set, the
editor functions as a selectable label. User interaction cannot change
the contents of the editor (though application code can still use the
API).
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit unifies and fixes the shaper's handling of the alignment
minimum width. Previously it was only considered when the text was
a single line, but in hindsight that was clearly a mistake. Now the
maximum width of all shaped lines and the minimum width is used to
set the text alignment.
This commit also fixes an index test in package widget that was
relying on the old (incorrect) alignment behavior.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit extends the editor to keep track of its own minimum constraint
and to provide that value to the text shaper for the purpose of aligning
text. Without this, the shaper does not know how much of the width of the
editor to use for alignment purposes.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit updates the textIterator and glyphIndex types to consume
new flag information provided on glyphs. These changes allow widget.Label
and widget.Editor to correctly compute text bounding boxes and to
generate valid cursor positions at the end of text.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit unifies all widget text painting to use a single function
and fixes two bugs that could result in visible glyphs failing to be
painted.
The first bug was that we checked whether a particular glyph's
outline was visible within the viewport and terminated iteration the
first time that we found a glyph that wasn't visible. If the very top
of the next line of text was visible within the viewport, taller glyphs
should be painted since part of them is visible. We would stop as soon
as we got to a short glyph, preventing the rest of the line (and any
tall glyphs it contained) from being painted.
I fixed this first problem by using the ascent/descent of the line containing
a glyph to determine whether it's "visible". While this will conclude that
a small glyph is visible when it may be entirely off-screen, the net result
will be that we will paint the entire line containing the glyph rather than
constructing a special version of the line with only the tall glyphs. This
has better path caching performance, as we don't need a bespoke path for when
the line is partially visible.
The second bug was that when the glyph iterator concluded that the
current glyph was out of the viewport, we would immediately terminate
the loop for painting glyphs without painting any buffered glyphs that
had been determined to be visible.
This second bug was easily fixed by ensuring that we always paint all buffered
glyphs when terminating iteration.
As part of this work, I pulled the (fairly complex) logic of buffering and
painting glyphs into the glyph iterator so that label and editor can share
a single implementation.
I was unable to completely encapsulate the array storing buffered glyphs within
the iterator without it being moved to the heap, so the current glyph iteration
API requires the caller to juggle a slice of glyphs. Hopefully someone in
the future can find a structure that the compiler's escape analysis understands.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit restructures the entire text shaping stack to enable lines of shaped text to
have non-homogeneous properties like which font face they belong to and which direction
a segment of text is going.
The text package now provides a concrete type text.Shaper which can be used to convert
strings into sequences of renderable text.Glyphs. At a high level, the API is used
like this:
// Prepare some fonts.
var collection []text.FontFace
// Make a shaper with those fonts loaded.
shaper := text.NewShaper(collection)
// Shape a string.
shaper.LayoutString(text.Parameters{
PxPerEm: fixed.I(12),
}, 0, 100, system.Locale{}, "Hello")
// Iterate the glyphs from that string.
for glyph, ok := shaper.NextGlyph(); ok; glyph, ok = shaper.NextGlyph() {
// Convert the glyph data into a path. In real uses, convert batches of glyphs
// rather than single glyphs to reduce the number of individual paths and offsets
// required to display your text.
shape := shaper.Shape([]text.Glyph{glyph})
// Offset the glyph to the position it declares within its fields. This will
// automatically handle correct bidirectional text glyph positioning.
offset := op.Offset(image.Pt(glyph.X.Floor(), int(glyph.Y))).Push(gtx.Ops)
// Create a clip area from the shape of the glyph.
area := clip.Outline{Path: shape}.Push(gtx.Ops)
// Paint whatever the current color is within the glyph's shape.
paint.PaintOp{}.Add(gtx.Ops)
area.Pop()
offset.Pop()
}
This API will transparently handle both font fallback (choosing appropriate fonts
from those loaded when the primary font doesn't contain a required glyph) and
bidirectional text (mixed left-to-right and right-to-left text). Glyphs are
iterated in order of the input runes, not their visual order, but proper use
of the provided offsets will ensure that text always displays correctly.
Thanks to Elias Naur for suggesting this glyph iterator strategy. It let us cut
through a lot of accumulated complexity from trying to match our old text APIs,
meaning that this change actually is a net negative change in lines of code.
This commit consumes the upstream github.com/go-text/typesetting/shaping API
now that my prior work is merged there, removing the need for the font/opentype/internal
package entirely.
As part of my efforts, I fuzzed both the low-level text shaping stack and the
editor widget extensively. I've committed regression tests found that way into
the appropriate testdata files to ensure the fuzzer re-checks them.
Fixes: https://todo.sr.ht/~eliasnaur/gio/425
Fixes: https://todo.sr.ht/~eliasnaur/gio/211
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit replaces invalid UTF8 codepoints with the replacement character
when they are inserted into the editor. This ensures that the editor never
moves the editing gap to an invalid location and reads its contents.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds a series of benchmarks for text rendering. They are intended
to capture the performance of static and continuously changing text within
labels and editors, and will serve as a baseline to compare the post-bidi
text stack against.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit fixes the expectations of our ligature iteration tests to
match the new behavior of the text position iterator. Now the cursor
can reach the position after the final glyph on a line, if that glyph
is not a newline.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit redefines incrementing a combinedPos to either move a single
rune forward, *or* transition from EOL->BOL, *or* both. This allows traversal
of lines without a trailing newline character to reach the position after the
final glyph of content.
Additionally, this commit updates positionGreaterOrEqual to explicitly handle
hard newlines via special-case logic, allowing lines without a hard newline to
avoid the newline-based short-circuit logic that would prevent them from iteratively
reaching the combinedPos following the final glyph on the line.
Fixes: https://todo.sr.ht/~eliasnaur/gio/400
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds a test for the seekPosition helper, a function which can
be used to move a combinedPos forward through a body of text until it approaches
a position.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds an exhaustive test case for the positionGreaterOrEqual
helper function that our text widgets use to compare locations within
shaped text.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit adds documentation and tests for the clusterIndexFor helper,
making it easier to understand what it does and how to use it safely.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>