Skip to content

Text Input fixes - Scrolling, highlighting - #53

Open
hoseyjoe wants to merge 11 commits into
haxeui:masterfrom
hoseyjoe:master
Open

Text Input fixes - Scrolling, highlighting#53
hoseyjoe wants to merge 11 commits into
haxeui:masterfrom
hoseyjoe:master

Conversation

@hoseyjoe

@hoseyjoe hoseyjoe commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Comments ion the commits

Joe Williamson and others added 11 commits August 4, 2026 14:48
ScreenImpl handed its listeners whatever was sitting in `event.screenX/Y`
when it ran. That is the single MouseEvent instance MouseHelper dispatches
to every listener in turn, and ComponentImpl.eventToCamera writes its own
transformed coordinates back into it - so what a Screen level listener saw
depended on whether a component handler happened to run first:

    Toolkit.scale = 1.5, one synthetic move at window x = 600
      with a Button on screen : Screen listener saw 400  (layout units)
      with no component mouse
      handlers registered     : Screen listener saw 600  (raw window px)

The camera transform was never applied either, so a scene with an
interactiveCamera at any non-identity scale or offset got raw window
coordinates in both cases.

Everything that tracks a drag through Screen.instance - splitters,
sliders, scrollbar thumbs, menus, drag and drop - compares these
coordinates against component positions, which are in layout units, so
the wrong answer is a drag that runs away from the cursor.

Seeds from the untransformed hxd.Event (never from the shared event's
already-mutated screenX/Y, the same reason ComponentImpl.eventToCamera
does), applies the interactive camera, then divides by Toolkit.scaleX/Y -
so Screen level and component level events now arrive in the same space,
deterministically.
h2d.TextInput draws its selection by emitting a tile from the text itself,
which cannot work for a signed distance field font. The SDF shader is added to
the text drawable, so every tile that drawable emits goes through it, and the
shader ends

    textureColor = vec4(1.0, 1.0, 1.0, smoothstep(...));

discarding the tile's colour and keeping only its coverage. The highlight
therefore arrives painted in the colour of the text, as a solid bar over
exactly the words it is meant to be showing. There is no translucent version
to settle for either: coverage is a smoothstep whose smoothing comes from the
gradient across the texture, and a flat colour has no gradient, so the bar is
fully opaque or entirely absent.

A separate object carries its own shaders and none of them is that one, so the
highlight is now a Graphics of its own, drawn from the same measurements and in
the same index space h2d.TextInput uses for its own - cursorIndex and
selectionRange count the wrapped text, breaks included - while heaps' tile is
left with no height and so covers nothing. It is re-emptied whenever the style
is validated, since that is where the font is set and h2d.Text.set_font builds
a fresh tile.

ComponentImpl puts it in the style graphics container, which is always the 0th
child and so is drawn before the text. Putting it in the component's own child
list would have put every component child one place out of step with
INDEX_OFFSET.

TextInputImpl.selectionColor sets the colour, defaulting to heaps' own blue so
that nothing which worked before looks any different.
…re laid out at

Toolkit.scaleX magnifies the whole component tree — ScreenImpl sets it on
every root component — but TextDisplayImpl sized the font from the style
alone. A style of 11px was therefore built for an 11 pixel em and stretched
over 16.5 physical pixels at a 1.5 scale: an 11 pixel rasterisation blown up,
with glyph advances that were whole numbers landing on thirds of a pixel.
Small text suffered most, which on a desktop tool is most of the text.

The font is now built at round(fontSize * Toolkit.scaleX) and the text stands
down by fontSize/deviceSize, so the same words occupy the same box out of a
rasterisation that matches the screen. The ratio is exact rather than 1/scale,
since the built size is a whole number: a 15pt label rounded to 23 device
pixels is still 15pt wide.

Three places read the text's own numbers and had to convert back to layout
units through sprite.scaleX: the measured width and height, the baseline
offset in validatePosition, and maxWidth in validateDisplay, which is where
the words wrap and is in the text's units rather than the component's.

At Toolkit.scaleX == 1 every line of this is an identity.
heaps renders a filtered object into a texture and composites it, sizing
that texture at viewportScaleX * resolutionScale (h2d.Object.drawFilters).
Toolkit.scaleX is neither of those - it magnifies the root COMPONENT - so
at a display scale the viewport stays 1, the texture is rasterised at
layout size, and the composite stretches it.

Everything a clip rect touches was therefore soft at any scale but 1:
every scrollview, list, tree and dropdown, while the text beside them
that no filter touches stayed sharp. The same fault the glyph-size fix
addresses for text drawn directly, one layer further out.

Set on the group createFilterGroup builds, which is the one place every
filter this backend makes comes from - and it is rebuilt by applyStyle
and handleClipRect, so a scale set before the UI is built (which is when
it has to be set anyway) is always the current one.

Measured on Joeditor's tree column at 1.5: mean edge gradient 3.066
before, 3.204 after, against 3.197 for the same content unfiltered. The
toolbar, which no filter touches, is 2.807 either way.
The two are different sums and they part company on a component whose
root is not at the origin.

cacheScreenPos adds every left up the chain and scales the TOTAL;
ComponentBase.get_screenBounds scales each left EXCEPT the root
component's own. For an ordinary root at left 0 those are the same
number, which is why everything inside a window hit-tests correctly and
this went unnoticed.

A dropdown's open list is not that. It is a root component of the
Screen, and DropDownHandler positions it with

    _wrapper.left = _dropdown.screenLeft + componentOffset.x

- a value already in screen pixels. Scaling the total scales it a second
time, and screenX stops describing where the component is.

screenBounds is the one that agrees with the sprite: measured on a
dropdown row at Toolkit.scale 1.5, screenBounds and the drawn sprite both
said 739,755 while screenX said 370,400. It is also what
ComponentBase.hitTest already compares against, so this puts the two hit
paths of this backend on one number instead of two.

Unpatched: at any scale but 1 a press on a row of an open dropdown list
falls straight through to whatever is behind it. Verified with Joeditor
--dropdown, which opens a list and presses a row where it is drawn: the
row answers at 1, 1.25, 1.5 and 1.75 with this applied and at 1 only
without it.
An unconditional trace of two widths in the multiple-masks offset loop,
left over from writing it - the identical loop for the height branch
immediately below has none, which is the tell.

It prints on every clip-rect update where a component has a sibling with
a mask of a different width, so a tableview or a window of nested
scrollviews would print it continuously. Nothing else in this file
traces, and everything else that does says WARNING and means it.
It referred to a sibling fix by name, which means nothing to a reader who
does not have that fix.
…'s own units

A multiline field was handed the component's width and height as the window it
clips its own drawing to, and the field draws in the units its glyphs are baked
in. Since Text builds glyphs at DEVICE size and stands the sprite down to match,
those are not the same units at any Toolkit scale but 1: at 1.5 the window came
out two thirds the size of the box, so every line was cut off two thirds of the
way across and the last third of the lines was not drawn at all. The box looked
right; the words in it were a window onto themselves.

The selection highlight had the mirror of it. Its bars are built from the
field's own measurements - calcTextWidth, lineHeight, the wrapped lines - and
were then drawn in the component's units, so at 1.5 they came out two thirds
the width of the words they were under and a line low.

So: one ratio (textScale), taken from the sprite the size of the glyphs already
decided, and applied at every crossing - the view, the scroll position (from
both the scrollbar and the caret-follow), the caret's line as it is read back,
and the highlight, which is now drawn at the same scale as the words it belongs
to. At scale 1 every one of those is an identity, which is why none of this
showed until a tool followed the desktop: rendered at scale 1 before and after,
the same screen is byte-identical.
… list

update() measured __timers.length once and reused it for both the fire loop and the cleanup sweep. Timer callbacks are arbitrary application code — they can add timers, stop timers, or re-enter update() (a callback that rebuilds part of a UI commonly ends up pumping the backend again). When one shortened the list, the sweep indexed past the end and dereferenced null: Null access ._stopped at TimerImpl.hx:21.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant