makepad/widgets/src/text_input.rs
Kevin Boos e0a5a23f2f
Splash improvements for running untrusted mini-apps (#1139)
* fix a pile of splash script-vm bugs: newline statements, short-circuit args, tail calls

went through the script VM and parser and fixed a batch of correctness bugs
that were biting the launcher's mini-apps:

- newline-delimited statements: a `(` or `[` at the start of the next line no
  longer greedily glues onto the previous value as a call/index. leading infix
  operators and `.` still continue the expression (the shader DSL needs that),
  and the divert is suppressed inside ()/[] groupings.
- short-circuit `&&`/`||` used as a call argument no longer loses its value to
  nil when the jump skips a multi-op right-hand side.
- a call as the very last statement of a script actually executes now, in both
  end-of-parse unwind loops (also patched a zero-offset ShortCircuitEnd).
- custom widgets that deref to a base with a #[source] field now forward
  script_source, so script_apply_eval works on them instead of silently no-op'ing.

plus regression tests for the newline and short-circuit cases.

* harden splash isolates: scoped timers, net gating, effective-visibility snapshots

isolate-safety work so mini-apps can't reach outside their sandbox:

- isolate-safe script-timer dispatch hook + gc for stale timers
- gate net.socket_stream on the net runtime being present
- widget-tree snapshot reports effective visibility (a widget counts as hidden
  if any ancestor is hidden)
- macos_activate_app (plus a headless no-op) so the launcher can focus itself

* widen the host->splash surface: splash setters, view/glassbutton script calls

everything the host needs to poke into a running mini-app's script:

- Splash: call_script_fn, set_script_global, set_allow_net, and a cached body id
  so host->script calls don't rescan for the body every time
- View.set_visible and GlassButton set_text/text are callable from script now
- makepad_test learned right-click (secondary button) so the headless tests can
  exercise long-press / context menus

* fix small-size glass lens + sdf box degeneration, warn on missing glyphs

visual correctness fixes we kept tripping over:

- cap the gauss lens band at 35% of the surface's smaller side so tiny discs
  degrade gracefully instead of smearing
- clamp the Sdf2d.box (and box_x/box_y/box_all) radius so an oversized radius
  saturates at a circle instead of collapsing into a rotated diamond
- log once per codepoint when no loaded font can render it (was silently
  drawing .notdef boxes)

* guard stale rect areas in clipped_rect/abs_to_rel/set_rect against out-of-bounds panics

* add switch_finger_capture to hand a live finger capture between widgets mid-drag

* add promote_finger_capture_over: hand a child-grabbed finger up to a co-capturing container

* splash: add validate_splash_body, a dry-run eval for externally-sourced scripts

evaluates a body in a throwaway isolate with the exact prefix/limits the
Splash widget uses and returns the captured script errors instead of logging
them. lets hosts that install source from outside (downloads, AI generation,
user input) reject bad scripts with real errors to show or feed back, where
the widget's own eval silently keeps the old view.

* strip mod.res from splash isolates; document validate_splash_body caveats

the res module's handles reach both the filesystem (abs_path loads) and the
network (web_url / http resources) without going through the gated net
runtime, so a 'no-net' isolate could still fetch and exfiltrate. found by an
adversarial review of AI-generated app installs, but it applies to any
untrusted splash source.

also note on validate_splash_body that the instruction limit bounds compute,
not heap growth, and that top-level timers live until isolate reclamation --
same exposure as actually installing the source, so validation adds nothing
new.

* splash: jailed per-app file storage (mod.fs inside isolates)

mini-apps get an OS-style private data directory, like an android app's
internal storage or an iOS container: the app sees a filesystem rooted at
"/", and that root IS its host-assigned sandbox directory
(Splash::set_sandbox_dir / SplashRef forwarder). registered as mod.fs in
isolates -- deliberately shadowing the stripped real fs module, so inside
an app "the filesystem" simply is the jail:

  fs.read fs.write fs.append fs.exists fs.remove fs.mkdir fs.list

containment lives entirely in the host layer:
- lexical path resolution against the root; `..` above the root, NUL, deep
  or overlong paths are errors before any I/O
- the per-VM root is rust state keyed by the isolate's heap -- script code
  can neither read nor retarget it
- symlink defense in depth: nothing here can create links, and every
  existing component under the root is verified non-symlink before use
- quotas: 1MB/file, 16MB/jail, 256 entries
- no root assigned (previews) -> every call errors cleanly

validate_splash_body gives dry runs a throwaway jail (temp dir, removed
after) so top-level fs.read boot loads validate instead of erroring. roots
are dropped with their isolates in the gc.

unit tests cover the containment: traversal/absolute/backslash escapes,
depth/name caps, and the symlink block.

* splash: put the jailed fs module in scope as a bare name

app scripts say fs.read("/x"), but the eval prefix only used the widgets
prelude, so bare fs resolved to a not-found error value and every storage
call failed silently. bind it in the prefix (let fs = mod.fs) for both the
plain and net variants; a script reassigning fs only shadows its own name,
the jail stays host-side.

* splash storage: quota + boundary hardening from adversarial review

three confirmed jail findings:
- mkdir bypassed every quota (target + create_dir_all, no jail_usage check)
  -> unbounded inode/dir-metadata exhaustion on the shared host volume.
  now charges new dirs against MAX_ENTRIES via missing_entries(); write's
  entry check does the same so a deep write can't overshoot the cap either.
- write/append/mkdir lacked remove's root guard: fs.write("/", data)
  resolved real == root and reached create_dir_all(root.parent()) -- one
  dir above the jail (the shared app_data/). now rejected like remove does.
- validate_splash_body's scratch jail used a predictable temp name created
  with create_dir_all (would follow a planted symlink out of temp). now an
  exclusive create_dir on a per-process+vm name (EEXIST-safe against a
  planted entry), reclaimed via gc before the dir is removed so a top-level
  timer can't resurrect it.

unit tests added for missing_entries; the containment tests still pass.

* splash: empty set_text tears down the isolate instead of no-oping

set_text("") was a silent no-op (eval_body early-returns on an empty body),
so a reused Splash that goes back to empty -- the widget-gallery live preview
on Back -- left its old isolate running its timers (and holding a storage-jail
binding) behind a blank view. now an empty body reclaims the isolate: the
isolate-minted view is replaced with a fresh empty one built in the main vm
BEFORE the isolate heap is freed, then the isolate is gc'd (stopping its
timers, dropping its jail root); vm_id resets to MAIN so a later non-empty
set_text allocs a fresh isolate as before. the existing host_launcher
teardown call sites (widget picker back()/reset()) become correct unchanged.

* overlay: composite glass in draw order, not creation order

every gauss/glass surface opens its own draw list and registers it in the
window's single Overlay via store_sub_list, which hands out the first free
slot and keeps it for the life of the process. renderers walk that table in
index order, so the paint order of all glass in an app was the order the
surfaces were first *created* — permanently, with freed slots reused by
whatever registered next. draw order never came into it, so a widget rebuilt
after a layout change, or a panel opened later, could land on top of anything
drawn after it. the only workarounds available to apps were "don't draw the
thing that's winning", which looks like a bug.

the hook for fixing it was already there and unused: CxDrawList's
draw_item_reorder, honoured by every backend (metal, d3d11, opengl, vulkan,
web_gl, headless raster). so stamp each overlay sub-list with the position it
was begun in this frame (Cx2d::overlay_seq, reset in Overlay::begin) and have
Overlay::end stable-sort the table by that stamp.

this also gets parent-then-child right without special cases, which matters
because glass.GlassButton / glass.GlassSegmented call begin_overlay_reuse
unconditionally instead of checking is_drawing_overlay(), so they hold their
own slots rather than riding their parent's.

* glass.GlassSegmented: size segments to their labels, add set_selected

three things, all of them things that looked broken to a user:

- segments were width/count, so "Max" got the same room as "Default": the long
  word crowded, the short one floated. each segment is now measured (DrawText
  layout size_in_lpxs) and gets its text plus padding, with leftover width
  shared equally so every label keeps the same margin. if the labels don't fit,
  the padding shrinks (never the text) to a floor. the pill's x/width are
  computed in rust and passed as uniforms since they can't come from a segment
  count any more, and hit-testing is a boundary lookup rather than a division.

- `selected` was public but the pill is drawn from a private sel_pos that only
  followed it via the click animation, so restoring a saved value from code
  left the control showing one segment while reporting another — and a click on
  the segment it really held was then ignored as "already selected". that reads
  as the control eating your clicks. set_selected keeps both in step.

- the travel easing was 0.30, which arrived before the eye could follow it.
  0.16.

* text_input: re-layout when max_lines changes

the laidout text was cached on width alone, so flipping draw_text.max_lines
at runtime (collapsing a composer to one line) kept the old multi-row layout
and the field never shrank. make max_lines part of the cache key.

* text_input: add set_max_lines instead of making callers script it

applying script to a TextInput re-applies its #[live] fields, and text is
one of them, so toggling max_lines through script_apply_eval! silently
wiped whatever the user had typed. give it a typed setter.

* text_input: don't drop the layout in set_max_lines

clearing laidout_text there leaves the field with no layout for the rest
of the event batch, so every cursor op in that window bails out with
"can't move cursor because layout was invalidated by an earlier event".
since set_max_lines gets called from focus/blur handling, that window is
exactly when you're clicking into the field — so the click placed no
caret at all. max_lines is already part of the layout cache key, so the
next draw re-lays out on its own.

* text_input: add scroll_to_top

for a field that folds to a fixed height when it loses focus: the scroll
offset survives the blur, so a draft last edited near its end folds
showing whichever line the caret had scrolled to rather than its first.
leaves laidout_text alone — scrolling doesn't change the layout, and
dropping it would break every cursor op for the rest of the event batch,
same trap as set_max_lines.

* text_input: add set_height

for a composer that folds to one line when it loses focus. pinning the
height is the safe way to fold — unlike clamping max_lines it leaves the
laid-out text alone, and the laid-out text is what maps a click to a
caret position. fold by re-layout and the press that re-focuses the
field resolves against the folded layout while the expanded one is on
screen, so the caret and any drag-selection land on the wrong text.

* text_input: add take_key_focus, which actually shows the caret

the caret draws as (1.0 - blink) * focus, and both come from animators
that only move when the widget is dealt a Hit::KeyFocus. setting key
focus on a field that ALREADY holds it dispatches no hit — so a field
that was focused, then hidden (hiding doesn't clear Cx's key focus) and
shown again comes back typable but with no caret and no selection
highlight, animators still parked where the last focus-lost left them.

plays focus.on unconditionally rather than only when focus changed:
repairing the case where it did NOT change is the entire point.

* splash: name scripts in errors, and stop using line as an identity slot

a runtime error from a Splash app logged `:1804943384:12 - widget has
no uid`: empty file, and a "line" that is really a pointer address.
the format is {file}:{line}:{col}, and both fields were casualties of
the same hack — ScriptMod.line carried self_id so the body could be
found again (m.line == self_id && m.file.is_empty()), while ip_to_loc
adds that same field to the script's real line when reporting. so every
location came out as real_line + a pointer, and nothing said WHICH app.

identity moves to module_path, which nothing else reads for these
bodies, freeing line to be a line. file gets a real name via a new
set_debug_name the host calls with the mini-app's id.

the validator's ScriptMod gets the same treatment — its errors are
shown to the user AND fed back to the agent as repair input, so a
location offset by a vm id was actively misleading there.

* splash: document the constant offset in reported script lines

the host prefix is two lines, so a reported line is two ahead of the
app's own file. it can't be zero — a zero-line prefix would share line 1
with the app's first line, and that line is always the // name: header,
which would comment the prefix out.

* script: stop silently losing widgets emitted from branches and loops

Splash mini-apps kept rendering nothing from on_render closures with zero
errors logged. Bisected live and in pure-VM probes, this was a pile of
separate bugs in the same corner:

- an if/else whose branch emits a widget lost the taken true branch: the
  statement's POP_TO_ME got fused onto the else tail, which the IF_ELSE
  jump skips. Generalized last_short_circuit_target into last_jump_target
  and record every branch join (if/elif/else, match, try/err), so the
  commit lands standalone AT the join and every path runs it. Same disease
  as the short-circuit-argument bug, new jump sites.
- elif never patched its arm's IF_ELSE jump (relative 0), so a taken arm
  spun the interpreter until the instruction limit killed the whole entry.
  elif now desugars into else { if ... } via IfElseExpr, which also gives
  its arms the join treatment.
- for x in <non-iterable> silently skipped the body where the equivalent
  while errored; now raises "for loop source is not iterable" (nil and
  empty sources stay silent). for k v in <number> passed key/index swapped
  and lost k after the first iteration (the advance only rebound v).
- a line-leading { after a value-ending line glued onto the previous
  expression as a proto instantiation; it now starts a new statement,
  same divert rule the ( and [ newline fix added.
- int literals were second-class numbers: U40/I32/F32/F16/U32 didn't
  collapse to the number bucket in to_redux, so 6 .is_number() missed
  method dispatch entirely and an int arg against a float default failed
  with "expected number, got number". They're all just numbers now.

Regression tests in tests/on_render_emission.rs cover each shape, incl.
the exact calendar/weather patterns that were blank in the launcher.

* splash: keep an on_render closure's final widget, and say when a render fails

Two host-side halves of the emission-loss story:

- the parser turns a closure's last statement into its return value, so a
  render closure that ENDS with a widget literal built it and then threw it
  away (this is why wrapping a whole render in one extra View{} produced
  nothing). script_result now pushes a returned widget object into me as
  the last child; non-widget returns still get skipped downstream.
- a render closure that errors mid-run used to have its output discarded
  with no diagnostics at all, which is what made this whole bug family
  cost days to find. Now it logs the error before dropping the result.

* script: auto-close still-open fn and let states at end of source

Both parse drivers dropped EndFnExpr/EndFnBlock/EmitLetDyn through their
auto-close catch-all when the source ended with them still open. A module
whose FINAL statement was let c = <lambda> got a body whose jump-over
stayed 0 — FN_BODY_DYN re-ran, found its me already popped, logged
"me stack is empty" and fell straight INTO the body, running it inline
at definition time and ending the module eval early — and the let itself
never emitted, so the binding silently didn't exist (same for
let c = <call>). Now those states close the way the live handlers do:
return + jump patch for the body, LET_DYN/LET_TYPED for the binding, and
the let's own EndStmt no longer marks a statement value (LET consumed
it; the final RETURN would pop an empty stack).

Fun consequence: the old test idiom of reading a result via
  let out = r
  out
only ever worked BECAUSE the trailing let was dropped — RETURN popped
the naked value off the stack. With the let actually binding, scripts
must end on a call (echo(r)); the emission tests are updated to do that.

Regression tests in tests/auto_close_eof.rs, including the exact
deferred-boot-timer closure shape the launcher apps use (which was
already fine — it just LOOKED guilty, see the splash commit).

* splash: probe optional script hooks without spamming the error log

call_script_fn checks whether the fn exists and bails quietly — but it
probed with a trapping scope_value, which had already queued a NotFound
by the time the miss was handled. Every host broadcast of an optional
hook (on_app_resize, on_widget_resize) against a script that doesn't
define it logged
  variable 00001e93e419c77c not found in scope
— maximally misleading: the hex is just id!(on_app_resize) (Rust-side
ids aren't in the reverse-lookup table so they print raw), and the
line:col is the stale ip from the end of that script's eval, which
pointed at whatever closure happened to be compiled last. In the
launcher that was the boot-timer line of every generated app, sending
the investigation down a deferred-closure rabbit hole the pure-VM tests
then cleared. Probe with NoTrap.

* script derive: don't name the eval values vec 'v'

script_apply_eval!'s generated values block bound 'let mut v' and then
spliced #(expr) interpolations in verbatim — so a caller interpolating a
variable that happened to be called v got the macro's own half-built Vec
(borrow errors if you were lucky, the wrong value if not). Obscure name
instead.

* headless: don't compile the Apple video path

Upstream's zero-copy video work put CoreVideo/Metal code in
gpu_texture.rs (plus two consumers) behind cfg(target_os = "macos")
alone. Under cfg(headless) the apple backend isn't built at all, so
every one of those symbols — ObjcId, msg_send!, CVPixelBufferRef,
CVMetalTextureCache* — is missing and makepad-platform fails to
compile with 94 errors. That takes the headless harness down with it,
which is what host_launcher's UI tests run on.

Gate the Apple blocks on not(headless) too. Nothing is lost: headless
has no Metal device to import a CVPixelBuffer into, so the whole path
is inapplicable there.

Not caused by the rebase — pristine dev has it: its headless
CxOsTexture is an empty struct while gpu_texture.rs reads .os.texture.
2026-08-12 01:55:46 +02:00

3699 lines
142 KiB
Rust

use {
crate::{
animator::{Animate, Animator, AnimatorAction, AnimatorImpl, Play},
makepad_derive_widget::*,
makepad_draw::{
event::finger::TouchState,
event::keyboard::CharOffset,
ime::{
AutoCapitalize, AutoCorrect, InputMode, ReturnKeyType, SoftKeyboardConfig,
TextInputConfig, TextInputContentType,
},
text::{
geom::Point,
layouter::{LaidoutText, SelectionRect},
selection::{Cursor, CursorPosition, Selection},
},
*,
},
makepad_script::{ScriptFnRef, ScriptRefOptionExt},
scroll_bar::{ScrollAxis, ScrollBar},
widget::*,
widget_async::{CxSplashVmExt, ScriptAsyncResult},
},
std::rc::Rc,
unicode_segmentation::{GraphemeCursor, UnicodeSegmentation},
};
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.TextInputBase = #(TextInput::register_widget(vm))
mod.widgets.TextInputFlat = set_type_default() do mod.widgets.TextInputBase{
width: Fill
height: Fit
padding: theme.mspace_1{left: theme.space_2, right: theme.space_2}
margin: theme.mspace_v_1
flow: Right {wrap: true}
is_password: false
is_read_only: false
is_numeric_only: false
empty_text: "Your text here"
scroll_bar: mod.widgets.ScrollBar {
bar_size: 8.0
bar_side_margin: 2.0
min_handle_size: 20.0
drag_scrolling: true
}
draw_bg +: {
hover: instance(0.0)
focus: instance(0.0)
down: instance(0.0)
disabled: instance(0.0)
empty: instance(0.0)
border_radius: uniform(theme.corner_radius)
border_size: uniform(theme.beveling)
gradient_border_horizontal: uniform(0.0)
gradient_fill_horizontal: uniform(0.0)
color_dither: uniform(1.0)
color: theme.color_inset
color_hover: uniform(theme.color_inset_hover)
color_focus: uniform(theme.color_inset_focus)
color_down: uniform(theme.color_inset_down)
color_empty: uniform(theme.color_inset_empty)
color_disabled: uniform(theme.color_inset_disabled)
color_2: uniform(vec4(-1.0, -1.0, -1.0, -1.0))
color_2_hover: uniform(theme.color_inset_2_hover)
color_2_focus: uniform(theme.color_inset_2_focus)
color_2_down: uniform(theme.color_inset_2_down)
color_2_empty: uniform(theme.color_inset_2_empty)
color_2_disabled: uniform(theme.color_inset_2_disabled)
border_color: uniform(theme.color_bevel)
border_color_hover: uniform(theme.color_bevel_hover)
border_color_focus: uniform(theme.color_bevel_focus)
border_color_down: uniform(theme.color_bevel_down)
border_color_empty: uniform(theme.color_bevel_empty)
border_color_disabled: uniform(theme.color_bevel_disabled)
border_color_2: uniform(vec4(-1.0, -1.0, -1.0, -1.0))
border_color_2_hover: uniform(theme.color_bevel_inset_2_hover)
border_color_2_focus: uniform(theme.color_bevel_inset_2_focus)
border_color_2_down: uniform(theme.color_bevel_inset_2_down)
border_color_2_empty: uniform(theme.color_bevel_inset_2_empty)
border_color_2_disabled: uniform(theme.color_bevel_inset_2_disabled)
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
let border_sz_uv = vec2(
self.border_size / self.rect_size.x
self.border_size / self.rect_size.y
)
let sz_inner_px = vec2(
self.rect_size.x - self.border_size * 2.
self.rect_size.y - self.border_size * 2.
)
let scale_factor_fill = vec2(
self.rect_size.x / sz_inner_px.x
self.rect_size.y / sz_inner_px.y
)
sdf.box(
self.border_size
self.border_size
self.rect_size.x - self.border_size * 2.
self.rect_size.y - self.border_size * 2.
self.border_radius
)
let mut color_fill = self.color
let mut color_fill_hover = self.color_hover
let mut color_fill_focus = self.color_focus
let mut color_fill_down = self.color_down
let mut color_fill_empty = self.color_empty
let mut color_fill_disabled = self.color_disabled
if self.color_2.x > -0.5 {
let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither
let gradient_fill = vec2(
self.pos.x * scale_factor_fill.x - border_sz_uv.x * 2. + dither
self.pos.y * scale_factor_fill.y - border_sz_uv.y * 2. + dither
)
let dir = if self.gradient_fill_horizontal > 0.5 gradient_fill.x else gradient_fill.y
color_fill = mix(self.color, self.color_2, dir)
color_fill_hover = mix(self.color_hover, self.color_2_hover, dir)
color_fill_focus = mix(self.color_focus, self.color_2_focus, dir)
color_fill_down = mix(self.color_down, self.color_2_down, dir)
color_fill_empty = mix(self.color_empty, self.color_2_empty, dir)
color_fill_disabled = mix(self.color_disabled, self.color_2_disabled, dir)
}
let mut color_stroke = self.border_color
let mut color_stroke_hover = self.border_color_hover
let mut color_stroke_focus = self.border_color_focus
let mut color_stroke_down = self.border_color_down
let mut color_stroke_empty = self.border_color_empty
let mut color_stroke_disabled = self.border_color_disabled
if self.border_color_2.x > -0.5 {
let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither
let gradient_border = vec2(
self.pos.x + dither
self.pos.y + dither
)
let dir = if self.gradient_border_horizontal > 0.5 gradient_border.x else gradient_border.y
color_stroke = mix(self.border_color, self.border_color_2, dir)
color_stroke_hover = mix(self.border_color_hover, self.border_color_2_hover, dir)
color_stroke_focus = mix(self.border_color_focus, self.border_color_2_focus, dir)
color_stroke_down = mix(self.border_color_down, self.border_color_2_down, dir)
color_stroke_empty = mix(self.border_color_empty, self.border_color_2_empty, dir)
color_stroke_disabled = mix(self.border_color_disabled, self.border_color_2_disabled, dir)
}
let fill = color_fill
.mix(color_fill_empty, self.empty)
.mix(color_fill_focus, self.focus)
.mix(color_fill_hover.mix(color_fill_down, self.down), self.hover)
.mix(color_fill_disabled, self.disabled)
let stroke = color_stroke
.mix(color_stroke_empty, self.empty)
.mix(color_stroke_focus, self.focus)
.mix(color_stroke_hover.mix(color_stroke_down, self.down), self.hover)
.mix(color_stroke_disabled, self.disabled)
sdf.fill_keep(fill)
sdf.stroke(stroke, self.border_size)
return sdf.result
}
}
draw_text +: {
hover: instance(0.0)
focus: instance(0.0)
down: instance(0.0)
empty: instance(0.0)
disabled: instance(0.0)
color: theme.color_text
color_hover: uniform(theme.color_text_hover)
color_focus: uniform(theme.color_text_focus)
color_down: uniform(theme.color_text_down)
color_disabled: uniform(theme.color_text_disabled)
color_empty: uniform(theme.color_text_placeholder)
color_empty_hover: uniform(theme.color_text_placeholder_hover)
color_empty_focus: uniform(theme.color_text_focus)
text_style: theme.font_regular{
line_spacing: theme.font_wdgt_line_spacing
font_size: theme.font_size_p
}
get_color: fn() {
return self.color
.mix(self.color_hover.mix(self.color_down, self.down), self.hover)
.mix(
self.color_empty
.mix(self.color_empty_hover, self.hover)
.mix(self.color_empty_focus, self.focus),
self.empty
)
.mix(self.color_focus, self.focus * (1.0 - self.empty))
.mix(self.color_disabled, self.disabled)
}
}
draw_selection +: {
hover: instance(0.0)
focus: instance(0.0)
down: instance(0.0)
empty: instance(0.0)
disabled: instance(0.0)
color_dither: uniform(1.0)
border_radius: uniform(theme.textselection_corner_radius)
gradient_fill_horizontal: uniform(0.0)
color: uniform(theme.color_selection)
color_hover: uniform(theme.color_selection_hover)
color_focus: uniform(theme.color_selection_focus)
color_down: uniform(theme.color_selection_down)
color_empty: uniform(theme.color_selection_empty)
color_disabled: uniform(theme.color_selection_disabled)
color_2: uniform(vec4(-1.0, -1.0, -1.0, -1.0))
color_2_hover: uniform(theme.color_selection_hover)
color_2_focus: uniform(theme.color_selection_focus)
color_2_down: uniform(theme.color_selection_down)
color_2_empty: uniform(theme.color_selection_empty)
color_2_disabled: uniform(theme.color_selection_disabled)
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(
0.0
0.0
self.rect_size.x
self.rect_size.y
self.border_radius
)
let mut color_fill = self.color
let mut color_fill_hover = self.color_hover
let mut color_fill_focus = self.color_focus
let mut color_fill_down = self.color_down
let mut color_fill_empty = self.color_empty
let mut color_fill_disabled = self.color_disabled
if self.color_2.x > -0.5 {
let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither
let dir = if self.gradient_fill_horizontal > 0.5 self.pos.x + dither else self.pos.y + dither
color_fill = mix(self.color, self.color_2, dir)
color_fill_hover = mix(self.color_hover, self.color_2_hover, dir)
color_fill_focus = mix(self.color_focus, self.color_2_focus, dir)
color_fill_down = mix(self.color_down, self.color_2_down, dir)
color_fill_empty = mix(self.color_empty, self.color_2_empty, dir)
color_fill_disabled = mix(self.color_disabled, self.color_2_disabled, dir)
}
let fill = color_fill
.mix(color_fill_empty, self.empty)
.mix(color_fill_focus, self.focus)
.mix(color_fill_hover.mix(color_fill_down, self.down), self.hover)
.mix(color_fill_disabled, self.disabled)
sdf.fill(fill)
return sdf.result
}
}
draw_cursor +: {
focus: instance(0.0)
down: instance(0.0)
empty: instance(0.0)
disabled: instance(0.0)
blink: instance(0.0)
border_radius: uniform(0.5)
color: uniform(theme.color_text_cursor)
pixel: fn() {
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
sdf.box(
0.0
0.0
self.rect_size.x
self.rect_size.y
self.border_radius
)
sdf.fill(
mix(theme.color_u_hidden, self.color, (1.0 - self.blink) * self.focus)
)
return sdf.result
}
}
draw_composition_underline +: {
color: uniform(#8)
pixel: fn() {
return self.color
}
}
animator: Animator{
empty: {
default: @off
off: AnimatorState{
from: {all: Forward {duration: 0.}}
apply: {
draw_bg: {empty: 0.0}
draw_text: {empty: 0.0}
draw_selection: {empty: 0.0}
draw_cursor: {empty: 0.0}
}
}
on: AnimatorState{
from: {all: Forward {duration: 0.2}}
apply: {
draw_bg: {empty: 1.0}
draw_text: {empty: 1.0}
draw_selection: {empty: 1.0}
draw_cursor: {empty: 1.0}
}
}
}
blink: {
default: @off
off: AnimatorState{
from: {all: Forward {duration: 0.05}}
apply: {
draw_cursor: {blink: 0.0}
}
}
on: AnimatorState{
from: {all: Forward {duration: 0.05}}
apply: {
draw_cursor: {blink: 1.0}
}
}
}
hover: {
default: @off
off: AnimatorState{
from: {all: Forward {duration: 0.1}}
apply: {
draw_bg: {down: 0.0, hover: 0.0}
draw_text: {down: 0.0, hover: 0.0}
}
}
on: AnimatorState{
from: {
all: Forward {duration: 0.1}
down: Forward {duration: 0.01}
}
apply: {
draw_bg: {down: 0.0, hover: snap(1.0)}
draw_text: {down: 0.0, hover: snap(1.0)}
}
}
down: AnimatorState{
from: {all: Forward {duration: 0.2}}
apply: {
draw_bg: {down: snap(1.0), hover: 1.0}
draw_text: {down: snap(1.0), hover: 1.0}
}
}
}
disabled: {
default: @off
off: AnimatorState{
from: {all: Forward {duration: 0.}}
apply: {
draw_bg: {disabled: 0.0}
draw_text: {disabled: 0.0}
draw_selection: {disabled: 0.0}
draw_cursor: {disabled: 0.0}
}
}
on: AnimatorState{
from: {all: Forward {duration: 0.2}}
apply: {
draw_bg: {disabled: 1.0}
draw_text: {disabled: 1.0}
draw_selection: {disabled: 1.0}
draw_cursor: {disabled: 1.0}
}
}
}
focus: {
default: @off
off: AnimatorState{
from: {all: Forward {duration: 0.25}}
apply: {
draw_bg: {focus: 0.0}
draw_text: {focus: 0.0}
draw_cursor: {focus: 0.0}
draw_selection: {focus: 0.0}
}
}
on: AnimatorState{
from: {all: Snap}
apply: {
draw_bg: {focus: 1.0}
draw_text: {focus: 1.0}
draw_cursor: {focus: 1.0}
draw_selection: {focus: 1.0}
}
}
}
}
}
mod.widgets.TextInput = mod.widgets.TextInputFlat{
draw_bg +: {
border_color: theme.color_bevel_inset_1
border_color_hover: theme.color_bevel_inset_1_hover
border_color_focus: theme.color_bevel_inset_1_focus
border_color_down: theme.color_bevel_inset_1_down
border_color_empty: theme.color_bevel_inset_1_empty
border_color_disabled: theme.color_bevel_inset_1_disabled
border_color_2: theme.color_bevel_inset_1
}
}
mod.widgets.TextInputGradientX = mod.widgets.TextInput{
draw_bg +: {
gradient_border_horizontal: 1.0
gradient_fill_horizontal: 1.0
color: theme.color_inset_1
color_hover: theme.color_inset_1_hover
color_focus: theme.color_inset_1_focus
color_down: theme.color_inset_1_down
color_empty: theme.color_inset_1_empty
color_disabled: theme.color_inset_1_disabled
color_2: theme.color_inset_2
}
draw_selection +: {
gradient_fill_horizontal: 1.0
color: theme.color_selection
color_hover: theme.color_selection_hover
color_focus: theme.color_selection_focus
color_down: theme.color_selection_down
color_empty: theme.color_selection_empty
color_disabled: theme.color_selection_disabled
color_2: theme.color_selection
color_2_hover: theme.color_selection_hover
color_2_focus: theme.color_selection_focus
color_2_down: theme.color_selection_down
color_2_empty: theme.color_selection_empty
color_2_disabled: theme.color_selection_disabled
}
}
mod.widgets.TextInputGradientY = mod.widgets.TextInputGradientX{
draw_bg +: {
gradient_border_horizontal: 0.0
gradient_fill_horizontal: 0.0
}
draw_selection +: {
gradient_fill_horizontal: 0.0
}
}
}
#[derive(Script, Widget, Animator)]
pub struct TextInput {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[apply_default]
animator: Animator,
#[redraw]
#[live]
draw_bg: DrawColor,
#[live]
draw_text: DrawText,
#[live]
draw_selection: DrawQuad,
#[live]
draw_cursor: DrawQuad,
#[live]
draw_composition_underline: DrawQuad,
#[layout]
layout: Layout,
#[walk]
walk: Walk,
#[live]
label_align: Align,
#[live]
is_password: bool,
#[live]
is_read_only: bool,
#[live]
is_numeric_only: bool,
#[live]
input_mode: InputMode,
#[live]
content_type: TextInputContentType,
#[live]
autocapitalize: AutoCapitalize,
#[live]
autocorrect: AutoCorrect,
#[live]
return_key_type: ReturnKeyType,
#[live(false)]
is_multiline: bool,
/// When `is_multiline` is true, controls whether plain Enter inserts a
/// newline or emits the `Returned` action.
///
/// * `false` (default): plain Enter inserts a newline; the `Returned`
/// action is emitted for Primary modifier + Enter (Cmd on macOS,
/// Ctrl on other platforms).
/// * `true`: plain Enter emits `Returned`; Shift+Enter inserts a newline.
///
/// Regardless of this setting, Primary modifier + Enter always emits
/// `Returned`, and Shift+Enter always inserts a newline.
///
/// Ignored when `is_multiline` is false (single-line inputs always emit
/// `Returned` on Enter).
#[live(false)]
submit_on_enter: bool,
#[live]
scroll_bar: ScrollBar,
#[live]
scroll_y: f64,
/// Horizontal scroll offset for single-line mode (in logical pixels).
#[rust]
scroll_x: f64,
#[live]
empty_text: String,
#[live]
text: String,
#[live(0.5)]
blink_speed: f64,
#[rust]
password_text: String,
#[rust]
laidout_text: Option<Rc<LaidoutText>>,
#[rust]
laidout_width: Option<f32>,
/// `draw_text.max_lines` as of the cached layout. Part of the cache key:
/// clamping a field to one row is a runtime change (collapse/expand), and
/// without this the cached multi-row layout survives it.
#[rust]
laidout_max_lines: usize,
#[rust]
text_area: Area,
#[rust]
selection: Selection,
#[rust]
history: History,
#[rust]
blink_timer: Timer,
#[rust]
preserved_selection_cursor: Option<Cursor>,
/// When true, the next draw will scroll to keep the cursor visible.
/// Set when the cursor/selection changes; cleared after scroll_to_cursor runs.
#[rust(true)]
needs_scroll_to_cursor: bool,
/// Cached maximum vertical scroll offset from the last draw pass. Used during event
/// handling to ensure boundary checks match exactly (avoiding floating-point
/// mismatch between draw-time and event-time computations).
#[rust]
cached_max_scroll_y: f64,
/// Cached maximum horizontal scroll offset from the last draw pass.
#[rust]
cached_max_scroll_x: f64,
/// Caret rect relative to the draw_bg box, cached each draw. This offset is
/// stable across draw-list spaces, so we add the box's window position later.
#[rust]
cached_caret_offset: DVec2,
#[rust]
cached_caret_size: DVec2,
/// Skip finger move after long press to prevent selection changes
#[rust]
ignore_next_move: bool,
/// Touch that started outside this input while focused and may blur on release.
#[rust]
pending_outside_focus_loss_touch: Option<u64>,
/// IME composition tracking - byte index where composition starts
#[rust]
composition_start: usize,
/// IME composition tracking - byte index where composition ends
#[rust]
composition_end: usize,
/// Frame ID when IME input was last received (echo prevention)
#[rust]
ime_update_frame: u64,
/// Cached text last sent to platform IME (echo prevention)
#[rust]
last_sent_ime_text: String,
/// Cached selection start (byte index) last sent to platform IME
#[rust]
last_sent_ime_sel_start: usize,
/// Cached selection end (byte index) last sent to platform IME
#[rust]
last_sent_ime_sel_end: usize,
#[live]
on_change: Option<ScriptFnRef>,
#[live]
on_return: Option<ScriptFnRef>,
}
impl ScriptHook for TextInput {
fn on_after_new(&mut self, vm: &mut ScriptVm) {
vm.with_cx_mut(|cx| {
self.check_text_is_empty(cx);
});
}
}
impl TextInput {
fn emit_change(&mut self, cx: &mut Cx, uid: WidgetUid) {
cx.widget_action(uid, TextInputAction::Changed(self.text.clone()));
if let Some(handler) = self.on_change.as_object() {
let text = self.text.clone();
let vm_id = cx.script_ref_vm_id(&self.source);
cx.with_script_vm_id(vm_id, |vm| {
let str_val = vm.bx.heap.new_string_from_str(&text);
vm.with_instruction_limit(
crate::widget_async::WIDGET_SCRIPT_INSTRUCTION_LIMIT,
|vm| {
vm.call(ScriptValue::from(handler), &[ScriptValue::from(str_val)]);
},
);
});
}
}
fn emit_return(&mut self, cx: &mut Cx, uid: WidgetUid, mods: KeyModifiers) {
cx.widget_action(uid, TextInputAction::Returned(self.text.clone(), mods));
if let Some(handler) = self.on_return.as_object() {
let text = self.text.clone();
let vm_id = cx.script_ref_vm_id(&self.source);
cx.with_script_vm_id(vm_id, |vm| {
let str_val = vm.bx.heap.new_string_from_str(&text);
vm.with_instruction_limit(
crate::widget_async::WIDGET_SCRIPT_INSTRUCTION_LIMIT,
|vm| {
vm.call(ScriptValue::from(handler), &[ScriptValue::from(str_val)]);
},
);
});
}
}
pub fn is_multiline(&self) -> bool {
self.is_multiline
}
/// Returns whether this (multiline) text input emits `Returned` on plain
/// Enter. See the [`TextInput::submit_on_enter`] field for details.
pub fn submit_on_enter(&self) -> bool {
self.submit_on_enter
}
/// Sets whether this (multiline) text input emits `Returned` on plain
/// Enter. See the [`TextInput::submit_on_enter`] field for details.
pub fn set_submit_on_enter(&mut self, submit_on_enter: bool) {
self.submit_on_enter = submit_on_enter;
}
pub fn set_is_multiline(&mut self, cx: &mut Cx, is_multiline: bool) {
self.is_multiline = is_multiline;
if !is_multiline {
self.scroll_y = 0.0;
self.cached_max_scroll_y = 0.0;
}
self.scroll_x = 0.0;
self.cached_max_scroll_x = 0.0;
self.laidout_text = None;
self.draw_bg.redraw(cx);
}
/// Clamps how many rows the field lays out; 0 means unlimited.
///
/// Rows made by hard newlines count too, so this genuinely folds a
/// multi-line draft to one line rather than only disabling soft wrap —
/// which is what makes it usable for a composer that collapses when it
/// loses focus. Pair with `draw_text.text_overflow = Ellipsis` to mark the
/// truncation.
///
/// Exists so callers don't have to reach for a script apply to change one
/// number: applying script to a TextInput resets its `#[live]` fields, and
/// `text` is one of them — so doing this the scripted way silently emptied
/// the field.
pub fn set_max_lines(&mut self, cx: &mut Cx, max_lines: usize) {
if self.draw_text.max_lines == max_lines {
return;
}
self.draw_text.max_lines = max_lines;
// Deliberately does NOT clear `laidout_text`. `max_lines` is part of
// the layout cache key, so the next draw re-lays out anyway — whereas
// dropping the layout HERE leaves the field with none for the rest of
// the event batch, and every cursor operation in that window fails
// ("can't move cursor because layout was invalidated by an earlier
// event") and silently returns. Since this is called from focus and
// blur handling, that window is exactly when the user is clicking into
// the field, so the click would place no caret at all.
self.draw_bg.redraw(cx);
}
pub fn max_lines(&self) -> usize {
self.draw_text.max_lines
}
/// Takes key focus AND turns the caret on.
///
/// Use this instead of the bare `Widget::set_key_focus` whenever the app
/// hands focus to a field itself. The caret is drawn as
/// `(1.0 - blink) * focus`, and both come from the `focus`/`blink`
/// animators, which only move when the widget is dealt a `Hit::KeyFocus`.
/// Setting key focus on a field that ALREADY holds it is a no-op at the
/// platform level — no hit is dispatched — so a field that was focused,
/// then hidden (hiding doesn't clear `Cx`'s key focus) and shown again
/// comes back typable but with no caret and no selection highlight, its
/// animators still parked where the last focus-lost left them.
pub fn take_key_focus(&mut self, cx: &mut Cx) {
cx.set_key_focus(self.draw_bg.area());
// Unconditional, not gated on the focus actually changing: the whole
// point is to repair the visuals when it did NOT.
self.animator_play(cx, ids!(focus.on));
self.reset_blink_timer(cx);
self.draw_bg.redraw(cx);
}
/// Overrides the height this field asks its parent for.
///
/// For a composer that folds to one line when it loses focus. Pinning the
/// HEIGHT is the safe way to do that — unlike clamping `max_lines`, it
/// leaves the laid-out text alone, and the laid-out text is what maps a
/// click to a caret position. Fold by re-layout and the press that
/// re-focuses the field resolves against the folded layout while the
/// expanded one is on screen, putting the caret and any drag-selection on
/// the wrong text. Overflow is clipped, so pick a whole number of lines or
/// the last one is sliced through the middle of its glyphs.
pub fn set_height(&mut self, cx: &mut Cx, height: Size) {
self.walk.height = height;
self.draw_bg.redraw(cx);
}
/// Scrolls back to the very start of the text.
///
/// For a field that folds down to a fixed height when it loses focus: the
/// scroll offset survives the blur, so a draft last edited near its end
/// would fold showing whichever line the caret had scrolled to rather than
/// its first. Deliberately leaves `laidout_text` alone — scrolling doesn't
/// change the layout, and dropping it here would break every cursor
/// operation for the rest of the event batch (see `set_max_lines`).
pub fn scroll_to_top(&mut self, cx: &mut Cx) {
if self.scroll_x == 0.0 && self.scroll_y == 0.0 {
return;
}
self.scroll_x = 0.0;
self.scroll_y = 0.0;
self.draw_bg.redraw(cx);
}
pub fn is_password(&self) -> bool {
self.is_password
}
pub fn set_is_password(&mut self, cx: &mut Cx, is_password: bool) {
self.is_password = is_password;
self.laidout_text = None;
self.draw_bg.redraw(cx);
}
pub fn toggle_is_password(&mut self, cx: &mut Cx) {
self.set_is_password(cx, !self.is_password);
}
pub fn is_read_only(&self) -> bool {
self.is_read_only
}
pub fn set_is_read_only(&mut self, cx: &mut Cx, is_read_only: bool) {
self.is_read_only = is_read_only;
// Flipped to read-only while focused: drop the keyboard now, since the draw
// path no longer re-shows it (gated on !is_read_only). Guard against Area::Empty
// (a never-drawn/reapplied field) so we don't false-match an Empty key focus.
let area = self.draw_bg.area();
if is_read_only && !area.is_empty() && cx.has_key_focus(area) {
cx.hide_text_ime();
}
self.laidout_text = None;
self.draw_bg.redraw(cx);
}
pub fn toggle_is_read_only(&mut self, cx: &mut Cx) {
self.set_is_read_only(cx, !self.is_read_only);
}
pub fn is_numeric_only(&self) -> bool {
self.is_numeric_only
}
pub fn set_is_numeric_only(&mut self, cx: &mut Cx, is_numeric_only: bool) {
self.is_numeric_only = is_numeric_only;
self.laidout_text = None;
self.draw_bg.redraw(cx);
}
pub fn toggle_is_numeric_only(&mut self, cx: &mut Cx) {
self.set_is_numeric_only(cx, !self.is_numeric_only);
}
pub fn empty_text(&self) -> &str {
&self.empty_text
}
pub fn set_empty_text(&mut self, cx: &mut Cx, empty_text: String) {
self.empty_text = empty_text;
if self.text.is_empty() {
self.draw_bg.redraw(cx);
}
}
pub fn selection(&self) -> Selection {
self.selection
}
pub fn set_selection(&mut self, cx: &mut Cx, selection: Selection) {
self.selection = Selection {
anchor: Cursor {
index: floor_grapheme_boundary(&self.text, selection.anchor.index),
prefer_next_row: selection.anchor.prefer_next_row,
},
cursor: Cursor {
index: floor_grapheme_boundary(&self.text, selection.cursor.index),
prefer_next_row: selection.cursor.prefer_next_row,
},
};
self.needs_scroll_to_cursor = true;
self.clear_composition();
self.history.force_new_edit_group();
self.draw_bg.redraw(cx);
}
pub fn cursor(&self) -> Cursor {
self.selection.cursor
}
pub fn set_cursor(&mut self, cx: &mut Cx, cursor: Cursor, keep_selection: bool) {
self.set_selection(
cx,
Selection {
anchor: if keep_selection {
self.selection.anchor
} else {
cursor
},
cursor,
},
);
}
pub fn selected_text(&self) -> &str {
&self.text[self.selection.start().index..self.selection.end().index]
}
pub fn reset_blink_timer(&mut self, cx: &mut Cx) {
self.animator_cut(cx, ids!(blink.off));
if !self.is_read_only {
cx.stop_timer(self.blink_timer);
self.blink_timer = cx.start_timeout(self.blink_speed)
}
}
fn cursor_to_position(&self, cursor: Cursor) -> Result<CursorPosition, ()> {
let Some(laidout_text) = self.laidout_text.as_ref() else {
return Err(());
};
let position = laidout_text.cursor_to_position(self.cursor_to_password_cursor(cursor));
Ok(CursorPosition {
row_index: position.row_index,
x_in_lpxs: position.x_in_lpxs * self.draw_text.font_scale,
})
}
fn point_in_lpxs_to_cursor(&self, point_in_lpxs: Point<f32>) -> Result<Cursor, ()> {
let Some(laidout_text) = self.laidout_text.as_ref() else {
return Err(());
};
let cursor =
laidout_text.point_in_lpxs_to_cursor(point_in_lpxs / self.draw_text.font_scale);
Ok(self.password_cursor_to_cursor(cursor))
}
fn position_to_cursor(&self, position: CursorPosition) -> Result<Cursor, ()> {
let Some(laidout_text) = self.laidout_text.as_ref() else {
return Err(());
};
let cursor = laidout_text.position_to_cursor(CursorPosition {
row_index: position.row_index,
x_in_lpxs: position.x_in_lpxs / self.draw_text.font_scale,
});
Ok(self.password_cursor_to_cursor(cursor))
}
fn selection_to_password_selection(&self, selection: Selection) -> Selection {
Selection {
cursor: self.cursor_to_password_cursor(selection.cursor),
anchor: self.cursor_to_password_cursor(selection.anchor),
}
}
fn cursor_to_password_cursor(&self, cursor: Cursor) -> Cursor {
Cursor {
index: self.index_to_password_index(cursor.index),
prefer_next_row: cursor.prefer_next_row,
}
}
fn password_cursor_to_cursor(&self, password_cursor: Cursor) -> Cursor {
Cursor {
index: self.password_index_to_index(password_cursor.index),
prefer_next_row: password_cursor.prefer_next_row,
}
}
fn index_to_password_index(&self, index: usize) -> usize {
if !self.is_password {
return index;
}
let grapheme_index = self.text[..index].graphemes(true).count();
self.password_text
.grapheme_indices(true)
.nth(grapheme_index)
.map_or(self.password_text.len(), |(index, _)| index)
}
fn password_index_to_index(&self, password_index: usize) -> usize {
if !self.is_password {
return password_index;
}
let grapheme_index = self.password_text[..password_index].graphemes(true).count();
self.text
.grapheme_indices(true)
.nth(grapheme_index)
.map_or(self.text.len(), |(index, _)| index)
}
fn inner_walk(&self) -> Walk {
if self.walk.width.is_fit() {
Walk::fit()
} else {
Walk::fill_fit()
}
}
fn layout_text(&mut self, cx: &mut Cx2d) {
let turtle_rect = cx.turtle().inner_rect();
// For single-line mode, don't constrain the max width so the text lays out
// at its natural width. This allows us to detect overflow and scroll horizontally.
// For multiline mode, constrain to the available width for proper wrapping.
let max_width_in_lpxs = if self.is_multiline && !turtle_rect.size.x.is_nan() {
Some(turtle_rect.size.x as f32)
} else {
None
};
if self.laidout_text.is_some()
&& self.laidout_width == max_width_in_lpxs
&& self.laidout_max_lines == self.draw_text.max_lines
{
return;
}
let text = if self.is_password {
self.password_text.clear();
for grapheme in self.text.graphemes(true) {
self.password_text
.push(if grapheme == "\n" { '\n' } else { '•' });
}
&self.password_text
} else {
&self.text
};
let wrap = self.is_multiline && cx.turtle().layout().flow == Flow::right_wrap();
self.laidout_width = max_width_in_lpxs;
self.laidout_max_lines = self.draw_text.max_lines;
self.laidout_text = Some(self.draw_text.layout(
cx,
0.0,
0.0,
max_width_in_lpxs,
wrap,
self.label_align,
text,
));
}
fn draw_text(&mut self, cx: &mut Cx2d) -> Rect {
let inner_walk = self.inner_walk();
let text_rect = if self.text.is_empty() {
self.draw_text
.draw_walk(cx, inner_walk, self.label_align, &self.empty_text)
} else {
let laidout_text = self.laidout_text.as_ref().unwrap();
self.draw_text
.draw_walk_laidout(cx, inner_walk, laidout_text)
};
cx.add_aligned_rect_area(&mut self.text_area, text_rect);
text_rect
}
/// The caret's rect (top-left + size, line height included) in window coords:
/// the draw_bg box's window position plus the cached box-relative offset. Query
/// during events, not draw, or the box position is draw-local. None before first draw.
pub fn cursor_rect_in_absolute(&self, cx: &Cx) -> Option<Rect> {
if self.cached_caret_size.y <= 0.0 {
return None;
}
Some(Rect {
pos: self.draw_bg.area().rect(cx).pos + self.cached_caret_offset,
size: self.cached_caret_size,
})
}
fn draw_cursor(&mut self, cx: &mut Cx2d, text_rect: Rect) -> Rect {
let CursorPosition {
row_index,
x_in_lpxs,
} = self
.cursor_to_position(self.selection.cursor)
.ok()
.expect("layout should not be `None` because we called `layout_text` in `draw_walk`");
// For multiline, clamp cursor x to viewport width to prevent it from
// extending past the right edge. For single-line, don't clamp because
// the text may be scrolled horizontally, and the clip rect handles overflow.
let x_in_lpxs = if self.is_multiline {
x_in_lpxs.min(cx.turtle().inner_rect().size.x as f32 - 2.0)
} else {
x_in_lpxs
};
let laidout_text = self
.laidout_text
.as_ref()
.expect("layout should not be `None` because we called `layout_text` in `draw_walk`");
let row = &laidout_text.rows[row_index];
let cursor_rect = rect(
(x_in_lpxs - 1.0 * self.draw_text.font_scale) as f64,
((row.origin_in_lpxs.y - row.ascender_in_lpxs) * self.draw_text.font_scale) as f64,
(2.0 * self.draw_text.font_scale) as f64,
((row.ascender_in_lpxs - row.descender_in_lpxs) * self.draw_text.font_scale) as f64,
);
self.draw_cursor
.draw_abs(cx, cursor_rect.translate(text_rect.pos));
cursor_rect
}
// The caret/composition-line rectangle (top-left + size, line height included)
// in IME-area-relative coordinates. Backends forward this to the OS so the
// candidate window anchors directly above/below the line near the cursor.
fn ime_position_rect(&self, cx: &Cx, text_rect: Rect, position: CursorPosition) -> Rect {
let area_rect = self.draw_bg.area().rect(cx);
let laidout_text = self
.laidout_text
.as_ref()
.expect("layout should not be `None` because we called `layout_text` in `draw_walk`");
let row = &laidout_text.rows[position.row_index];
let line_top =
((row.origin_in_lpxs.y - row.ascender_in_lpxs) * self.draw_text.font_scale) as f64;
let line_height =
((row.ascender_in_lpxs - row.descender_in_lpxs) * self.draw_text.font_scale) as f64;
let top_left = text_rect.pos - area_rect.pos
+ dvec2(position.x_in_lpxs as f64, line_top)
- dvec2(self.scroll_x, self.scroll_y);
Rect {
pos: top_left,
size: dvec2((2.0 * self.draw_text.font_scale) as f64, line_height),
}
}
fn ime_cursor_rect(&self, cx: &Cx, text_rect: Rect, cursor_rect: Rect) -> Rect {
if !self.has_composition() && self.selection.cursor.index > 0 {
let previous_index = prev_grapheme_boundary(&self.text, self.selection.cursor.index);
let previous_cursor = Cursor {
index: previous_index,
prefer_next_row: false,
};
let end_cursor = Cursor {
index: self.selection.cursor.index,
prefer_next_row: false,
};
if let (Ok(previous_position), Ok(end_position)) = (
self.cursor_to_position(previous_cursor),
self.cursor_to_position(end_cursor),
) {
let anchor_position = if previous_position.row_index == end_position.row_index {
CursorPosition {
row_index: previous_position.row_index,
x_in_lpxs: (previous_position.x_in_lpxs + end_position.x_in_lpxs) * 0.5,
}
} else {
previous_position
};
return self.ime_position_rect(cx, text_rect, anchor_position);
}
}
let area_rect = self.draw_bg.area().rect(cx);
Rect {
pos: text_rect.pos - area_rect.pos + cursor_rect.pos
- dvec2(self.scroll_x, self.scroll_y),
size: cursor_rect.size,
}
}
fn draw_selection(&mut self, cx: &mut Cx2d, text_rect: Rect) {
let laidout_text = self
.laidout_text
.as_ref()
.expect("layout should not be `None` because we called `layout_text` in `draw_walk`");
self.draw_selection.begin_many_instances(cx);
for SelectionRect { rect_in_lpxs, .. } in
laidout_text.selection_rects(self.selection_to_password_selection(self.selection))
{
self.draw_selection.draw_abs(
cx,
rect(
text_rect.pos.x + (rect_in_lpxs.origin.x * self.draw_text.font_scale) as f64,
text_rect.pos.y + (rect_in_lpxs.origin.y * self.draw_text.font_scale) as f64,
(rect_in_lpxs.size.width * self.draw_text.font_scale) as f64,
(rect_in_lpxs.size.height * self.draw_text.font_scale) as f64,
),
);
}
self.draw_selection.end_many_instances(cx);
}
/// Calculate the bounding rectangle of the current text selection in screen coordinates
/// We use the draw_bg area which should give us the actual drawn position
fn get_selection_rect(&self, cx: &Cx) -> Rect {
let widget_rect = self.draw_bg.area().rect(cx);
// If no layout yet, return a small rect below the widget
let Some(laidout_text) = self.laidout_text.as_ref() else {
return rect(
widget_rect.pos.x,
widget_rect.pos.y + widget_rect.size.y,
10.0,
20.0,
);
};
// Get all selection rectangles
let selection_rects =
laidout_text.selection_rects(self.selection_to_password_selection(self.selection));
if selection_rects.is_empty() {
// No selection, return position below the widget
return rect(
widget_rect.pos.x,
widget_rect.pos.y + widget_rect.size.y,
10.0,
20.0,
);
}
// Calculate bounding box of all selection rects
let first = &selection_rects[0].rect_in_lpxs;
let mut min_x = first.origin.x;
let mut min_y = first.origin.y;
let mut max_x = first.origin.x + first.size.width;
let mut max_y = first.origin.y + first.size.height;
for SelectionRect { rect_in_lpxs, .. } in selection_rects.iter().skip(1) {
min_x = min_x.min(rect_in_lpxs.origin.x);
min_y = min_y.min(rect_in_lpxs.origin.y);
max_x = max_x.max(rect_in_lpxs.origin.x + rect_in_lpxs.size.width);
max_y = max_y.max(rect_in_lpxs.origin.y + rect_in_lpxs.size.height);
}
// Convert to screen coordinates using widget position as base
let text_offset_x = widget_rect.pos.x + self.layout.padding.left as f64;
let text_offset_y = widget_rect.pos.y + self.layout.padding.top as f64;
let sel_x = text_offset_x + (min_x * self.draw_text.font_scale) as f64 - self.scroll_x;
let sel_y = text_offset_y + (min_y * self.draw_text.font_scale) as f64 - self.scroll_y;
let sel_width = ((max_x - min_x) * self.draw_text.font_scale) as f64;
let sel_height = ((max_y - min_y) * self.draw_text.font_scale) as f64;
rect(sel_x, sel_y, sel_width.max(10.0), sel_height.max(20.0))
}
fn scroll_to_cursor(&mut self, cx: &mut Cx2d, content_clip_index: usize) {
// Compute the final size of the turtle, and obtain its inner dimensions.
// For multiline inputs, also clamp to the tightest ancestor max height,
// so that scrolling kicks in even when the TextInput's own walk height
// is unbounded Fit (relying on ancestors for the constraint).
cx.compute_final_size();
if self.is_multiline {
let ancestor_max = cx.compute_max_height_from_ancestors();
if ancestor_max < f64::MAX {
let turtle = cx.turtle_mut();
if turtle.height() > ancestor_max {
turtle.set_height(ancestor_max);
}
}
}
// For single-line inputs with Fit width, clamp to the tightest ancestor
// max width so that horizontal scrolling kicks in when the text overflows
// the available space (e.g., parent has a fixed width).
if !self.is_multiline && self.walk.width.is_fit() {
let ancestor_max = cx.compute_max_width_from_ancestors();
if ancestor_max < f64::MAX {
let turtle = cx.turtle_mut();
if turtle.width() > ancestor_max {
turtle.set_width(ancestor_max);
}
}
}
let inner_rect = cx.turtle().inner_rect();
let height = inner_rect.size.y;
let width = inner_rect.size.x;
// Only auto-scroll to keep the cursor visible when the cursor has actually
// moved (typing, arrow keys, clicking). Don't do this on every redraw, as
// that would fight with user-initiated mouse wheel scrolling.
if self.needs_scroll_to_cursor {
self.needs_scroll_to_cursor = false;
let laidout_text = self.laidout_text.as_ref().unwrap();
if self.is_multiline {
let position = self.cursor_to_position(self.cursor()).unwrap();
let laidout_row = &laidout_text.rows[position.row_index];
let y_min = (laidout_row.origin_in_lpxs.y - laidout_row.ascender_in_lpxs) as f64;
let y_max = (laidout_row.origin_in_lpxs.y - laidout_row.descender_in_lpxs) as f64;
// If the min y of the row is less than the scroll position, scroll up so
// that the top of the row appears at the top.
if y_min < self.scroll_y {
self.scroll_y = y_min;
}
// If the max y of the row is greater than the scroll position, scroll
// down so that the bottom of the row appears at the bottom.
if y_max > self.scroll_y + height {
self.scroll_y = y_max - height;
}
} else {
// Single-line: auto-scroll horizontally to keep the cursor visible.
let password_cursor = self.cursor_to_password_cursor(self.cursor());
let cursor_pos = laidout_text.cursor_to_position(password_cursor);
let cursor_x = cursor_pos.x_in_lpxs as f64;
// If the cursor is to the left of the visible area, scroll left.
if cursor_x < self.scroll_x {
self.scroll_x = cursor_x;
}
// If the cursor is to the right of the visible area, scroll right.
if cursor_x > self.scroll_x + width {
self.scroll_x = cursor_x - width;
}
}
}
// Always clamp the scroll positions to valid bounds, and cache
// the max values so the event handler uses the exact same values
// (avoiding floating-point mismatch with relative Fit bounds).
let laidout_text = self.laidout_text.as_ref().unwrap();
let laidout_text_height = laidout_text.size_in_lpxs.height as f64;
if self.is_multiline {
let max_scroll_y = (laidout_text_height - height).max(0.0);
self.cached_max_scroll_y = max_scroll_y;
self.scroll_y = self.scroll_y.max(0.0).min(max_scroll_y);
} else {
// Single-line: nothing scrolls vertically, so the offset centres the
// line box in the inner rect instead. Pinning to padding.top would
// ride high in tall fields and, because a font's line box is taller
// than its point size suggests, clip descenders in tight ones. Going
// through scroll_y keeps every downstream coordinate (hit test, IME,
// selection rects) consistent, since they all compensate for it.
let text_height =
(laidout_text.size_in_lpxs.height * self.draw_text.font_scale) as f64;
let centering = (text_height - height) * 0.5;
self.cached_max_scroll_y = 0.0;
// Guard NaN (an unresolved turtle size), which the old clamp
// sanitized implicitly and which would poison the align shift.
self.scroll_y = if centering.is_finite() { centering } else { 0.0 };
}
let laidout_text_width = laidout_text.size_in_lpxs.width as f64;
let max_scroll_x = (laidout_text_width - width).max(0.0);
self.cached_max_scroll_x = max_scroll_x;
self.scroll_x = self.scroll_x.max(0.0).min(max_scroll_x);
// Shift the align range of the turtle with the scroll position, but do not include the
// begin entry, since that would also scroll the background.
let align_range: TurtleAlignRange = cx.get_turtle_align_range();
cx.shift_align_range(
&TurtleAlignRange {
start: align_range.start + 1,
end: align_range.end,
},
dvec2(-self.scroll_x, -self.scroll_y),
);
// Update the content clip rect AFTER shift_align_range, because the shift
// also moves BeginClip entries. By setting the clip here, we override
// whatever shift was applied, keeping the clip at the correct absolute
// position. Multiline clips to the inner rect (scrolled content must not
// bleed into the padding); single-line clips vertically to the whole
// padded box, because the centred line box may legitimately overhang the
// padding (descenders) and should only be cut by the background box.
let content_clip = if self.is_multiline {
inner_rect
} else {
let outer_rect = cx.turtle().rect();
rect(
inner_rect.pos.x,
outer_rect.pos.y,
inner_rect.size.x,
outer_rect.size.y,
)
};
cx.update_clip_rect_at(content_clip_index, content_clip);
}
/// Draws the vertical scrollbar when the text content overflows the visible area.
fn draw_scroll_bar(&mut self, cx: &mut Cx2d) {
if !self.is_multiline {
return;
}
let Some(laidout_text) = self.laidout_text.as_ref() else {
return;
};
let view_rect = cx.turtle().inner_rect();
let view_total = dvec2(view_rect.size.x, laidout_text.size_in_lpxs.height as f64);
// Sync scroll_y (which scroll_to_cursor may have updated) into the scrollbar.
self.scroll_bar.set_scroll_pos_no_action(cx, self.scroll_y);
self.scroll_bar
.draw_scroll_bar(cx, ScrollAxis::Vertical, view_rect, view_total);
}
/// Moves the cursor one column to the left.
///
/// Returns `true` if the cursor/selection actually changed.
pub fn move_cursor_left(&mut self, cx: &mut Cx, keep_selection: bool) -> bool {
let initial = self.selection;
if !keep_selection && self.selection.cursor != self.selection.anchor {
self.set_cursor(cx, self.selection.start(), false);
return !initial.index_eq(self.selection);
}
self.set_cursor(
cx,
Cursor {
index: prev_grapheme_boundary(&self.text, self.selection.cursor.index),
prefer_next_row: true,
},
keep_selection,
);
!initial.index_eq(self.selection)
}
/// Moves the cursor one column to the right.
///
/// Returns `true` if the cursor/selection actually changed.
pub fn move_cursor_right(&mut self, cx: &mut Cx, keep_selection: bool) -> bool {
let initial = self.selection;
if !keep_selection && self.selection.cursor != self.selection.anchor {
self.set_cursor(cx, self.selection.end(), false);
return !initial.index_eq(self.selection);
}
self.set_cursor(
cx,
Cursor {
index: next_grapheme_boundary(&self.text, self.selection.cursor.index),
prefer_next_row: false,
},
keep_selection,
);
!initial.index_eq(self.selection)
}
pub fn move_cursor_word_left(&mut self, cx: &mut Cx, keep_selection: bool) -> bool {
let initial = self.selection;
self.set_cursor(
cx,
Cursor {
index: prev_word_boundary(&self.text, self.selection.cursor.index),
prefer_next_row: true,
},
keep_selection,
);
!initial.index_eq(self.selection)
}
pub fn move_cursor_word_right(&mut self, cx: &mut Cx, keep_selection: bool) -> bool {
let initial = self.selection;
self.set_cursor(
cx,
Cursor {
index: next_word_boundary(&self.text, self.selection.cursor.index),
prefer_next_row: false,
},
keep_selection,
);
!initial.index_eq(self.selection)
}
pub fn move_cursor_line_start(
&mut self,
cx: &mut Cx,
keep_selection: bool,
) -> Result<bool, ()> {
let initial = self.selection;
let position = self.cursor_to_position(self.selection.cursor)?;
let index = {
let laidout_text = self.laidout_text.as_ref().ok_or(())?;
laidout_text.rows[position.row_index].text.start_in_parent()
};
let cursor = self.password_cursor_to_cursor(Cursor {
index,
prefer_next_row: true,
});
self.set_cursor(cx, cursor, keep_selection);
Ok(!initial.index_eq(self.selection))
}
pub fn move_cursor_line_end(
&mut self,
cx: &mut Cx,
keep_selection: bool,
) -> Result<bool, ()> {
let initial = self.selection;
let position = self.cursor_to_position(self.selection.cursor)?;
let index = {
let laidout_text = self.laidout_text.as_ref().ok_or(())?;
laidout_text.rows[position.row_index].text.end_in_parent()
};
let cursor = self.password_cursor_to_cursor(Cursor {
index,
prefer_next_row: false,
});
self.set_cursor(cx, cursor, keep_selection);
Ok(!initial.index_eq(self.selection))
}
pub fn move_cursor_text_start(&mut self, cx: &mut Cx, keep_selection: bool) -> bool {
let initial = self.selection;
self.set_cursor(
cx,
Cursor {
index: 0,
prefer_next_row: false,
},
keep_selection,
);
!initial.index_eq(self.selection)
}
pub fn move_cursor_text_end(&mut self, cx: &mut Cx, keep_selection: bool) -> bool {
let initial = self.selection;
self.set_cursor(
cx,
Cursor {
index: self.text.len(),
prefer_next_row: false,
},
keep_selection,
);
!initial.index_eq(self.selection)
}
/// Moves the cursor one line (row) up.
///
/// * Returns Ok(`true`) if the cursor/selection actually changed.
/// * Returns Ok(`false`) if the cursor/selection movement was properly handled but did not change,
/// e.g., if the cursor was already at the top-most row.
/// * Returns `Err` if the cursor/selection failed to be calculated due to a prior layout invalidation.
pub fn move_cursor_up(&mut self, cx: &mut Cx, keep_selection: bool) -> Result<bool, ()> {
let initial = self.selection;
let position = self.cursor_to_position(self.selection.cursor)?;
self.set_cursor(
cx,
self.position_to_cursor(CursorPosition {
row_index: if position.row_index == 0 {
0
} else {
position.row_index - 1
},
x_in_lpxs: position.x_in_lpxs,
})?,
keep_selection,
);
Ok(!initial.index_eq(self.selection))
}
/// Moves the cursor one line (row) down.
///
/// * Returns Ok(`true`) if the cursor/selection actually changed.
/// * Returns Ok(`false`) if the cursor/selection movement was properly handled but did not change,
/// e.g., if the cursor was already at the bottom-most row.
/// * Returns Err(`()`) if the cursor/selection failed to be calculated due to a prior layout invalidation.
pub fn move_cursor_down(&mut self, cx: &mut Cx, keep_selection: bool) -> Result<bool, ()> {
let initial = self.selection;
let laidout_text = self.laidout_text.as_ref().ok_or(())?;
let position = self.cursor_to_position(self.selection.cursor)?;
self.set_cursor(
cx,
self.position_to_cursor(CursorPosition {
row_index: if position.row_index == laidout_text.rows.len() - 1 {
laidout_text.rows.len() - 1
} else {
position.row_index + 1
},
x_in_lpxs: position.x_in_lpxs,
})?,
keep_selection,
);
Ok(!initial.index_eq(self.selection))
}
pub fn move_cursor_page_up(
&mut self,
cx: &mut Cx,
keep_selection: bool,
) -> Result<bool, ()> {
if !self.is_multiline {
return Ok(self.move_cursor_text_start(cx, keep_selection));
}
let initial = self.selection;
let position = self.cursor_to_position(self.selection.cursor)?;
let target_row_index = {
let laidout_text = self.laidout_text.as_ref().ok_or(())?;
let view_height = self.page_height_in_lpxs(cx);
let target_y = laidout_text.rows[position.row_index].origin_in_lpxs.y - view_height;
let mut target_row_index = position.row_index;
while target_row_index > 0
&& laidout_text.rows[target_row_index - 1].origin_in_lpxs.y >= target_y
{
target_row_index -= 1;
}
if target_row_index == position.row_index && target_row_index > 0 {
target_row_index -= 1;
}
target_row_index
};
self.set_cursor(
cx,
self.position_to_cursor(CursorPosition {
row_index: target_row_index,
x_in_lpxs: position.x_in_lpxs,
})?,
keep_selection,
);
Ok(!initial.index_eq(self.selection))
}
pub fn move_cursor_page_down(
&mut self,
cx: &mut Cx,
keep_selection: bool,
) -> Result<bool, ()> {
if !self.is_multiline {
return Ok(self.move_cursor_text_end(cx, keep_selection));
}
let initial = self.selection;
let position = self.cursor_to_position(self.selection.cursor)?;
let target_row_index = {
let laidout_text = self.laidout_text.as_ref().ok_or(())?;
let view_height = self.page_height_in_lpxs(cx);
let target_y = laidout_text.rows[position.row_index].origin_in_lpxs.y + view_height;
let mut target_row_index = position.row_index;
while target_row_index + 1 < laidout_text.rows.len()
&& laidout_text.rows[target_row_index + 1].origin_in_lpxs.y <= target_y
{
target_row_index += 1;
}
if target_row_index == position.row_index
&& target_row_index + 1 < laidout_text.rows.len()
{
target_row_index += 1;
}
target_row_index
};
self.set_cursor(
cx,
self.position_to_cursor(CursorPosition {
row_index: target_row_index,
x_in_lpxs: position.x_in_lpxs,
})?,
keep_selection,
);
Ok(!initial.index_eq(self.selection))
}
fn page_height_in_lpxs(&self, cx: &Cx) -> f32 {
let rect = self.draw_bg.area().rect(cx);
let height = rect.size.y - self.layout.padding.top - self.layout.padding.bottom;
(height.max(0.0) as f32 / self.draw_text.font_scale.max(0.001)).max(1.0)
}
pub fn select_all(&mut self, cx: &mut Cx) {
self.set_selection(
cx,
Selection {
anchor: Cursor {
index: 0,
prefer_next_row: false,
},
cursor: Cursor {
index: self.text.len(),
prefer_next_row: false,
},
},
);
}
pub fn select_word(&mut self, cx: &mut Cx) {
if self.selection.cursor.index < self.selection.anchor.index {
self.set_cursor(
cx,
Cursor {
index: self.ceil_word_boundary(self.selection.cursor.index),
prefer_next_row: true,
},
true,
);
} else if self.selection.cursor.index > self.selection.anchor.index {
self.set_cursor(
cx,
Cursor {
index: self.floor_word_boundary(self.selection.cursor.index),
prefer_next_row: false,
},
true,
);
} else {
self.set_selection(
cx,
Selection {
anchor: Cursor {
index: self.ceil_word_boundary(self.selection.cursor.index),
prefer_next_row: true,
},
cursor: Cursor {
index: self.floor_word_boundary(self.selection.cursor.index),
prefer_next_row: false,
},
},
);
}
}
pub fn force_new_edit_group(&mut self) {
self.history.force_new_edit_group();
}
fn handle_focus_lost(&mut self, cx: &mut Cx, uid: WidgetUid) {
self.animator_play(cx, ids!(focus.off));
self.animator_play(cx, ids!(blink.on));
cx.stop_timer(self.blink_timer);
cx.hide_text_ime();
self.composition_start = 0;
self.composition_end = 0;
self.pending_outside_focus_loss_touch = None;
// Only hide clipboard actions on mobile platforms where they're supported
match cx.os_type() {
OsType::Android(_) | OsType::Ios(_) => {
cx.hide_clipboard_actions();
}
_ => {}
}
cx.widget_action(uid, TextInputAction::KeyFocusLost);
}
fn has_composition(&self) -> bool {
self.composition_end > self.composition_start
}
fn clear_composition(&mut self) {
if self.composition_start == 0 && self.composition_end == 0 {
return;
}
self.composition_start = 0;
self.composition_end = 0;
// Force the next focused draw to sync `composition: None` to the platform.
self.last_sent_ime_sel_start = usize::MAX;
self.last_sent_ime_sel_end = usize::MAX;
}
fn get_ime_config(&self) -> TextInputConfig {
TextInputConfig {
soft_keyboard: SoftKeyboardConfig {
input_mode: self.effective_input_mode(),
// A password must never auto-capitalize or autocorrect.
autocapitalize: if self.is_password {
AutoCapitalize::None
} else {
self.autocapitalize
},
autocorrect: if self.is_password {
AutoCorrect::Disabled
} else {
self.autocorrect
},
return_key_type: self.return_key_type,
},
is_multiline: self.is_multiline,
is_secure: self.is_password,
submit_on_enter: self.submit_on_enter,
content_type: self.content_type,
is_read_only: self.is_read_only,
}
}
/// Resolves the soft-keyboard layout for this field.
///
/// An explicitly-set `input_mode` always wins. `InputMode::Text` is the
/// default ("no preference"), so we treat it as a request to infer a more
/// specific layout from the field's other hints: a numeric-only field gets
/// the decimal pad, otherwise the keyboard is derived from `content_type`
/// (e.g. an email field shows the email keyboard) so callers don't have to
/// set both. Inference only ever applies when `input_mode` was left at the
/// default, never overriding an explicit choice.
fn effective_input_mode(&self) -> InputMode {
if self.input_mode != InputMode::Text {
return self.input_mode;
}
if self.is_numeric_only {
return InputMode::Decimal;
}
match self.content_type {
TextInputContentType::EmailAddress => InputMode::Email,
TextInputContentType::Url => InputMode::Url,
TextInputContentType::TelephoneNumber => InputMode::Tel,
TextInputContentType::OneTimeCode => InputMode::Numeric,
// Username / passwords / street address / unset keep the full
// default keyboard (e.g. a password needs every character, and a
// username may be either an email or a handle).
TextInputContentType::None
| TextInputContentType::Username
| TextInputContentType::Password
| TextInputContentType::NewPassword
| TextInputContentType::FullStreetAddress => InputMode::Text,
}
}
fn update_ime_context(&mut self, cx: &mut Cx) {
if self.has_composition() {
return;
}
let sel_start_chars = self.text[..self.selection.start().index].chars().count();
let sel_end_chars = self.text[..self.selection.end().index].chars().count();
if self.text != self.last_sent_ime_text
|| self.selection.start().index != self.last_sent_ime_sel_start
|| self.selection.end().index != self.last_sent_ime_sel_end
{
self.last_sent_ime_text = self.text.clone();
self.last_sent_ime_sel_start = self.selection.start().index;
self.last_sent_ime_sel_end = self.selection.end().index;
cx.sync_ime_state(
self.text.clone(),
CharOffset(sel_start_chars)..CharOffset(sel_end_chars),
None,
);
}
}
fn draw_composition_underline(&mut self, cx: &mut Cx2d, text_rect: Rect) {
if !self.has_composition() {
return;
}
let laidout_text = self
.laidout_text
.as_ref()
.expect("layout should never be `None` here");
let composition_selection = Selection {
anchor: Cursor {
index: self.composition_start.min(self.text.len()),
prefer_next_row: false,
},
cursor: Cursor {
index: self.composition_end.min(self.text.len()),
prefer_next_row: false,
},
};
let selection = self.selection_to_password_selection(composition_selection);
let underline_height = 1.5 * self.draw_text.font_scale;
self.draw_composition_underline.begin_many_instances(cx);
for SelectionRect { rect_in_lpxs, .. } in laidout_text.selection_rects(selection) {
let scaled_x =
text_rect.pos.x + (rect_in_lpxs.origin.x * self.draw_text.font_scale) as f64;
let scaled_y = text_rect.pos.y
+ ((rect_in_lpxs.origin.y + rect_in_lpxs.size.height) * self.draw_text.font_scale)
as f64
- underline_height as f64;
let scaled_w = (rect_in_lpxs.size.width * self.draw_text.font_scale) as f64;
self.draw_composition_underline.draw_abs(
cx,
rect(scaled_x, scaled_y, scaled_w, underline_height as f64),
);
}
self.draw_composition_underline.end_many_instances(cx);
}
fn ceil_word_boundary(&self, index: usize) -> usize {
let mut prev_word_boundary_index = 0;
for (word_boundary_index, _) in self.text.split_word_bound_indices() {
if word_boundary_index > index {
return prev_word_boundary_index;
}
prev_word_boundary_index = word_boundary_index;
}
prev_word_boundary_index
}
fn floor_word_boundary(&self, index: usize) -> usize {
let mut prev_word_boundary_index = self.text.len();
for (word_boundary_index, _) in self.text.split_word_bound_indices().rev() {
if word_boundary_index < index {
return prev_word_boundary_index;
}
prev_word_boundary_index = word_boundary_index;
}
prev_word_boundary_index
}
fn filter_input(&self, input: &str, is_set_text: bool) -> String {
// strip control chars (escape sequences/tabs the IME sometimes sends),
// but keep a newline in multiline fields where a soft keyboard inserts it
if input.len() == 1 {
if let Some(char) = input.chars().next() {
if char.is_control() && !(char == '\n' && self.is_multiline) {
return String::new();
}
}
}
// Use input_mode for filtering; fall back to is_numeric_only for backwards compat
match self.effective_input_mode() {
InputMode::Ascii => input.chars().filter(|c| c.is_ascii()).collect(),
InputMode::Numeric => input.chars().filter(|c| c.is_ascii_digit()).collect(),
InputMode::Decimal => {
let mut contains_dot = if is_set_text {
false
} else {
let before_selection = self.text[..self.selection.start().index].to_string();
let after_selection = self.text[self.selection.end().index..].to_string();
before_selection.contains('.') || after_selection.contains('.')
};
input
.chars()
.filter(|c| match c {
'.' if !contains_dot => {
contains_dot = true;
true
}
'-' | '+' => true,
c => c.is_ascii_digit(),
})
.collect()
}
InputMode::Tel => input
.chars()
.filter(|c| {
c.is_ascii_digit() || matches!(c, '+' | '-' | ' ' | '(' | ')' | '*' | '#')
})
.collect(),
InputMode::None
| InputMode::Text
| InputMode::Url
| InputMode::Email
| InputMode::Search => input.to_string(),
}
}
fn create_or_extend_edit_group(&mut self, edit_kind: EditKind) {
self.history
.create_or_extend_edit_group(edit_kind, self.selection);
}
fn apply_edit(&mut self, cx: &mut Cx, edit: Edit) {
self.selection.cursor.index = edit.start + edit.replace_with.len();
self.selection.anchor.index = self.selection.cursor.index;
self.needs_scroll_to_cursor = true;
self.history.apply_edit(edit, &mut self.text);
self.laidout_text = None;
self.check_text_is_empty(cx);
}
fn char_range_to_byte_range(&self, start: usize, end: usize) -> (usize, usize) {
let byte_start = self
.text
.char_indices()
.nth(start)
.map(|(i, _)| i)
.unwrap_or(self.text.len());
let byte_end = self
.text
.char_indices()
.nth(end)
.map(|(i, _)| i)
.unwrap_or(self.text.len());
(byte_start.min(byte_end), byte_start.max(byte_end))
}
fn find_nearest_text_range(&self, needle: &str, preferred_start: usize) -> Option<(usize, usize)> {
if needle.is_empty() {
let start = preferred_start.min(self.text.len());
return Some((start, start));
}
let cursor = self.selection.cursor.index;
self.text
.match_indices(needle)
.min_by_key(|(start, _)| {
let after_cursor_penalty = if *start > cursor { self.text.len() } else { 0 };
(
after_cursor_penalty,
start.abs_diff(preferred_start),
start.abs_diff(cursor),
)
})
.map(|(start, text)| (start, start + text.len()))
}
fn resolve_external_replace_range(
&self,
byte_start: usize,
byte_end: usize,
replaced_text: Option<&str>,
) -> Option<(usize, usize)> {
let byte_start = byte_start.min(self.text.len());
let byte_end = byte_end.min(self.text.len()).max(byte_start);
let Some(replaced_text) = replaced_text else {
return Some((byte_start, byte_end));
};
if replaced_text.is_empty() {
return Some((byte_start, byte_end));
}
if self.text.get(byte_start..byte_end) == Some(replaced_text) {
return Some((byte_start, byte_end));
}
self.find_nearest_text_range(replaced_text, byte_start)
}
fn transform_index_after_edit(
text: &str,
index: usize,
start: usize,
end: usize,
replacement_len: usize,
) -> usize {
let transformed = if index <= start {
index
} else if index >= end {
let delta = replacement_len as isize - (end - start) as isize;
(index as isize + delta).max(0) as usize
} else {
start + replacement_len
};
floor_grapheme_boundary(text, transformed.min(text.len()))
}
fn apply_edit_preserving_selection(&mut self, cx: &mut Cx, edit: Edit) {
let selection = self.selection;
let start = edit.start;
let end = edit.end;
let replacement_len = edit.replace_with.len();
self.history.apply_edit(edit, &mut self.text);
self.selection = Selection {
anchor: Cursor {
index: Self::transform_index_after_edit(
&self.text,
selection.anchor.index,
start,
end,
replacement_len,
),
prefer_next_row: selection.anchor.prefer_next_row,
},
cursor: Cursor {
index: Self::transform_index_after_edit(
&self.text,
selection.cursor.index,
start,
end,
replacement_len,
),
prefer_next_row: selection.cursor.prefer_next_row,
},
};
self.needs_scroll_to_cursor = true;
self.laidout_text = None;
self.check_text_is_empty(cx);
}
fn undo(&mut self, cx: &mut Cx) -> bool {
if let Some(new_selection) = self.history.undo(self.selection, &mut self.text) {
self.laidout_text = None;
self.selection = new_selection;
self.needs_scroll_to_cursor = true;
self.check_text_is_empty(cx);
true
} else {
false
}
}
fn redo(&mut self, cx: &mut Cx) -> bool {
if let Some(new_selection) = self.history.redo(self.selection, &mut self.text) {
self.laidout_text = None;
self.selection = new_selection;
self.needs_scroll_to_cursor = true;
self.check_text_is_empty(cx);
true
} else {
false
}
}
fn check_text_is_empty(&mut self, cx: &mut Cx) {
if self.text.is_empty() {
self.animator_play(cx, ids!(empty.on));
} else {
self.animator_play(cx, ids!(empty.off));
}
}
fn handle_navigation_key(&mut self, cx: &mut Cx, uid: WidgetUid, event: KeyEvent) -> bool {
let Some(navigation) = TextNavigation::from_key_event(event) else {
return false;
};
self.reset_blink_timer(cx);
let keep_selection = event.modifiers.shift;
let result = match navigation {
TextNavigation::Left => Ok(self.move_cursor_left(cx, keep_selection)),
TextNavigation::Right => Ok(self.move_cursor_right(cx, keep_selection)),
TextNavigation::WordLeft => Ok(self.move_cursor_word_left(cx, keep_selection)),
TextNavigation::WordRight => Ok(self.move_cursor_word_right(cx, keep_selection)),
TextNavigation::Up => self.move_cursor_up(cx, keep_selection),
TextNavigation::Down => self.move_cursor_down(cx, keep_selection),
TextNavigation::LineStart => self.move_cursor_line_start(cx, keep_selection),
TextNavigation::LineEnd => self.move_cursor_line_end(cx, keep_selection),
TextNavigation::TextStart => Ok(self.move_cursor_text_start(cx, keep_selection)),
TextNavigation::TextEnd => Ok(self.move_cursor_text_end(cx, keep_selection)),
TextNavigation::PageUp => self.move_cursor_page_up(cx, keep_selection),
TextNavigation::PageDown => self.move_cursor_page_down(cx, keep_selection),
};
match result {
Ok(true) => {}
Ok(false) => cx.widget_action(uid, TextInputAction::KeyDownUnhandled(event)),
Err(_) => warning!("can't move cursor because layout was invalidated by earlier event"),
}
true
}
fn backspace_range(&self, modifiers: KeyModifiers) -> (usize, usize) {
let mut start = self.selection.start().index;
let end = self.selection.end().index;
if start != end {
return (start, end);
}
start = if is_line_delete_modifier(modifiers) {
self.current_line_start_index().unwrap_or(start)
} else if is_word_modifier(modifiers) {
prev_word_boundary(&self.text, start)
} else {
prev_grapheme_boundary(&self.text, start)
};
(start, end)
}
fn delete_range(&self, modifiers: KeyModifiers) -> (usize, usize) {
let start = self.selection.start().index;
let mut end = self.selection.end().index;
if start != end {
return (start, end);
}
end = if is_line_delete_modifier(modifiers) {
self.current_line_end_index().unwrap_or(end)
} else if is_word_modifier(modifiers) {
next_word_boundary(&self.text, end)
} else {
next_grapheme_boundary(&self.text, end)
};
(start, end)
}
fn current_line_start_index(&self) -> Result<usize, ()> {
let position = self.cursor_to_position(self.selection.cursor)?;
let laidout_text = self.laidout_text.as_ref().ok_or(())?;
let index = laidout_text.rows[position.row_index].text.start_in_parent();
Ok(self
.password_cursor_to_cursor(Cursor {
index,
prefer_next_row: true,
})
.index)
}
fn current_line_end_index(&self) -> Result<usize, ()> {
let position = self.cursor_to_position(self.selection.cursor)?;
let laidout_text = self.laidout_text.as_ref().ok_or(())?;
let index = laidout_text.rows[position.row_index].text.end_in_parent();
Ok(self
.password_cursor_to_cursor(Cursor {
index,
prefer_next_row: false,
})
.index)
}
}
impl Widget for TextInput {
fn script_call(
&mut self,
vm: &mut ScriptVm,
method: LiveId,
args: ScriptValue,
) -> ScriptAsyncResult {
if method == live_id!(text) {
let str_val = vm.bx.heap.new_string_from_str(&self.text);
return ScriptAsyncResult::Return(str_val.into());
}
if method == live_id!(set_text) {
if let Some(args_obj) = args.as_object() {
let trap = vm.bx.threads.cur().trap.pass();
let value = vm.bx.heap.vec_value(args_obj, 0, trap);
if !value.is_err() {
let new_text = vm.bx.heap.temp_string_with(|heap, out| {
heap.cast_to_string(value, out);
out.to_string()
});
vm.with_cx_mut(|cx| {
self.set_text(cx, &new_text);
});
}
}
return ScriptAsyncResult::Return(NIL);
}
ScriptAsyncResult::MethodNotFound
}
fn text(&self) -> String {
self.text.clone()
}
fn set_text(&mut self, cx: &mut Cx, text: &str) {
self.text = self.filter_input(text, true);
self.set_selection(
cx,
Selection {
anchor: Cursor {
index: floor_grapheme_boundary(&self.text, self.selection.anchor.index),
prefer_next_row: self.selection.anchor.prefer_next_row,
},
cursor: Cursor {
index: floor_grapheme_boundary(&self.text, self.selection.cursor.index),
prefer_next_row: self.selection.cursor.prefer_next_row,
},
},
);
self.history.clear();
self.laidout_text = None;
self.draw_bg.redraw(cx);
self.check_text_is_empty(cx);
}
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
self.draw_bg.begin(cx, walk, self.layout);
self.draw_selection.append_to_draw_call(cx);
self.draw_composition_underline.append_to_draw_call(cx);
// Push an inner clip rect to prevent scrolled text from bleeding into
// the padding area. For multiline, this clips vertically-scrolled content.
// For single-line, this clips horizontally-scrolled content that overflows.
// We use a placeholder rect here (inner_origin with large size);
// scroll_to_cursor will tighten the bounds after compute_final_size
// determines the actual dimensions.
let inner_origin = cx.turtle().inner_origin();
let content_clip_index =
cx.push_clip_rect_tracked(rect(inner_origin.x, inner_origin.y, f64::MAX, f64::MAX));
self.layout_text(cx);
let text_rect = self.draw_text(cx);
let cursor_rect = self.draw_cursor(cx, text_rect);
self.draw_selection(cx, text_rect);
self.draw_composition_underline(cx, text_rect);
self.scroll_to_cursor(cx, content_clip_index);
cx.pop_clip_rect();
self.draw_scroll_bar(cx);
self.draw_bg.end(cx);
// A read-only field does no IME work at all (no state push, no keyboard).
if cx.has_key_focus(self.draw_bg.area()) && !self.is_read_only {
// Cache the caret relative to the draw_bg box (same draw space) so
// cursor_rect_in_absolute can add the box's window position at event time.
// Only the focused field needs this, so we piggyback on the focus check.
// The scroll offset (which for single-line inputs includes the vertical
// centering shift) moves the drawn caret, so subtract it here too.
self.cached_caret_offset = text_rect.pos + cursor_rect.pos
- self.draw_bg.area().rect(cx).pos
- dvec2(self.scroll_x, self.scroll_y);
self.cached_caret_size = cursor_rect.size;
if self.ime_update_frame != cx.redraw_id() {
self.update_ime_context(cx);
}
if self.effective_input_mode() != InputMode::None {
let ime_cursor_rect = self.ime_cursor_rect(cx, text_rect, cursor_rect);
cx.show_text_ime_with_config(
self.draw_bg.area(),
ime_cursor_rect,
self.get_ime_config(),
);
}
}
cx.add_nav_stop(self.draw_bg.area(), NavRole::TextInput, Inset::default());
DrawStep::done()
}
fn set_disabled(&mut self, cx: &mut Cx, disabled: bool) {
self.animator_toggle(
cx,
disabled,
Animate::Yes,
ids!(disabled.on),
ids!(disabled.off),
);
}
fn disabled(&self, cx: &Cx) -> bool {
self.animator_in_state(cx, ids!(disabled.on))
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
if self.animator_handle_event(cx, event).must_redraw() {
self.draw_bg.redraw(cx);
self.draw_cursor.redraw(cx);
}
if self.blink_timer.is_event(event).is_some() {
if self.animator_in_state(cx, ids!(blink.off)) {
self.animator_play(cx, ids!(blink.on));
} else {
self.animator_play(cx, ids!(blink.off));
}
self.draw_cursor.redraw(cx);
self.blink_timer = cx.start_timeout(self.blink_speed)
}
let uid = self.widget_uid();
// Self-detect focus loss from taps outside our area
// But NOT if we've captured the finger (e.g., during a selection drag that ends outside)
if cx.has_key_focus(self.draw_bg.area())
&& !cx.fingers.is_area_captured(self.draw_bg.area())
{
let rect = self.draw_bg.area().rect(cx);
let should_lose_focus = match event {
// Handle desktop mouse clicks
Event::MouseUp(mu) => !rect.contains(mu.abs),
// Handle mobile touch events
Event::TouchUpdate(tu) => {
let mut should_lose_focus = false;
for touch in &tu.touches {
match touch.state {
TouchState::Start => {
if rect.contains(touch.abs) {
self.pending_outside_focus_loss_touch = None;
} else {
self.pending_outside_focus_loss_touch = Some(touch.uid);
}
}
TouchState::Stop => {
if self.pending_outside_focus_loss_touch == Some(touch.uid) {
should_lose_focus = !rect.contains(touch.abs);
self.pending_outside_focus_loss_touch = None;
}
}
_ => {}
}
}
should_lose_focus
}
_ => false,
};
if should_lose_focus {
// Update focus state in cx
cx.set_key_focus(Area::Empty);
// Handle focus loss locally
self.handle_focus_lost(cx, uid);
}
}
// Handle scrollbar events for multiline text inputs.
if self.is_multiline {
// Handle mouse wheel / trackpad scroll directly.
// Makepad convention: positive scroll.y = viewport moves down = scroll_pos increases.
// self.scroll_y is the single source of truth; the ScrollBar is synced from it
// during draw_scroll_bar, so we don't update the ScrollBar here (which would
// trigger next_frame callbacks and cause feedback loops).
if let Event::Scroll(e) = event {
let bg_rect = self.draw_bg.area().rect(cx);
if !e.handled_y.get() && bg_rect.contains(e.abs) {
// Use the cached max_scroll_y from the last draw pass to ensure
// boundary checks match exactly, avoiding floating-point mismatch
// with relative Fit bounds.
let max_scroll_y = self.cached_max_scroll_y;
if max_scroll_y > 0.0 {
let new_scroll_y = (self.scroll_y + e.scroll.y).max(0.0).min(max_scroll_y);
if new_scroll_y != self.scroll_y {
self.scroll_y = new_scroll_y;
self.draw_bg.redraw(cx);
e.handled_y.set(true);
}
}
}
}
// Handle clicking/dragging on the scrollbar handle itself.
// We pass an empty callback because we sync scroll_y from the ScrollBar
// below, only when the scrollbar has actually captured the finger.
self.scroll_bar.handle_event_with(cx, event, &mut |_, _| {});
// If the scrollbar has captured the finger (user is dragging the handle),
// sync scroll_y from the ScrollBar (which is the source of truth during
// drag).
if self.scroll_bar.is_area_captured(cx) {
self.scroll_y = self.scroll_bar.get_scroll_pos();
self.draw_bg.redraw(cx);
}
}
// Handle horizontal scroll events for single-line text inputs.
// Only consume horizontal scroll (scroll.x), NOT vertical scroll —
// vertical scroll should propagate to parent containers.
if !self.is_multiline {
if let Event::Scroll(e) = event {
if !e.handled_x.get() && e.scroll.x != 0.0 {
let bg_rect = self.draw_bg.area().rect(cx);
if bg_rect.contains(e.abs) {
let max_scroll_x = self.cached_max_scroll_x;
if max_scroll_x > 0.0 {
let new_scroll_x =
(self.scroll_x + e.scroll.x).max(0.0).min(max_scroll_x);
if new_scroll_x != self.scroll_x {
self.scroll_x = new_scroll_x;
self.draw_bg.redraw(cx);
e.handled_x.set(true);
}
}
}
}
}
}
// Skip finger event processing if the scrollbar owns the finger,
// so dragging the scrollbar doesn't also move the text cursor.
let scrollbar_captured = self.is_multiline && self.scroll_bar.is_area_captured(cx);
match event.hits(cx, self.draw_bg.area()) {
Hit::FingerHoverIn(_) => {
cx.set_cursor(MouseCursor::Text);
self.animator_play(cx, ids!(hover.on));
}
Hit::FingerHoverOut(_) => {
cx.set_cursor(MouseCursor::Default);
self.animator_play(cx, ids!(hover.off));
}
Hit::KeyFocus(_) => {
self.animator_play(cx, ids!(focus.on));
self.reset_blink_timer(cx);
// Sync text state to platform IME before keyboard shows
let sel_start_chars = self.text[..self.selection.start().index].chars().count();
let sel_end_chars = self.text[..self.selection.end().index].chars().count();
cx.sync_ime_state(
self.text.clone(),
CharOffset(sel_start_chars)..CharOffset(sel_end_chars),
None,
);
self.last_sent_ime_text = self.text.clone();
self.last_sent_ime_sel_start = self.selection.start().index;
self.last_sent_ime_sel_end = self.selection.end().index;
cx.widget_action(uid, TextInputAction::KeyFocus);
}
Hit::KeyFocusLost(_) => {
self.handle_focus_lost(cx, uid);
}
Hit::KeyDown(event) if self.handle_navigation_key(cx, uid, event) => {}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::KeyA,
modifiers,
..
}) if modifiers.is_primary() => {
self.select_all(cx);
// On touch platforms, show clipboard actions after select all
// This handles the case where select_all is triggered from the clipboard menu
#[cfg(any(target_os = "ios", target_os = "android"))]
{
let has_selection = !self.selected_text().is_empty();
let selection_rect = self.get_selection_rect(cx);
cx.show_clipboard_actions(has_selection, selection_rect, cx.keyboard_shift);
}
}
Hit::FingerDown(FingerDownEvent {
abs,
tap_count,
device,
..
}) if device.is_primary_hit() && !scrollbar_captured => {
self.reset_blink_timer(cx);
self.set_key_focus(cx);
let rel = abs - self.text_area.rect(cx).pos;
let Ok(cursor) =
self.point_in_lpxs_to_cursor(Point::new(rel.x as f32, rel.y as f32))
else {
warning!("can't move cursor because layout was invalidated by earlier event");
return;
};
let selection = self.selection();
let has_selection = selection.cursor != selection.anchor;
let touching_selection = if has_selection {
let sel_start = selection.start().index;
let sel_end = selection.end().index;
cursor.index >= sel_start && cursor.index <= sel_end
} else {
false
};
if tap_count > 1 || !touching_selection {
self.set_cursor(cx, cursor, false);
self.preserved_selection_cursor = None;
} else {
self.preserved_selection_cursor = Some(cursor);
}
match tap_count {
2 => {
self.select_word(cx);
if device.is_touch() {
let has_selection = !self.selected_text().is_empty();
let selection_rect = self.get_selection_rect(cx);
cx.show_clipboard_actions(
has_selection,
selection_rect,
cx.keyboard_shift,
);
}
}
3 => {
self.select_all(cx);
if device.is_touch() {
let has_selection = !self.selected_text().is_empty();
let selection_rect = self.get_selection_rect(cx);
cx.show_clipboard_actions(
has_selection,
selection_rect,
cx.keyboard_shift,
);
}
}
_ => {}
}
self.animator_play(cx, ids!(hover.down));
}
Hit::FingerUp(fe) => {
self.ignore_next_move = false;
if fe.was_tap() {
if let Some(cursor) = self.preserved_selection_cursor.take() {
self.set_cursor(cx, cursor, false);
}
} else {
self.preserved_selection_cursor = None;
}
if fe.is_over && fe.was_tap() {
if fe.has_hovers() {
self.animator_play(cx, ids!(hover.on));
} else {
self.animator_play(cx, ids!(hover.off));
}
} else {
self.animator_play(cx, ids!(hover.off));
}
}
Hit::FingerLongPress(lp) => {
self.preserved_selection_cursor = None;
// Select word at long press position
let rel = lp.abs - self.text_area.rect(cx).pos;
if let Ok(cursor) =
self.point_in_lpxs_to_cursor(Point::new(rel.x as f32, rel.y as f32))
{
// Check if cursor is over actual text
if cursor.index < self.text.len() {
self.set_cursor(cx, cursor, false);
self.select_word(cx);
} else {
// Long press on empty space just position the cursor
self.set_cursor(cx, cursor, false);
}
}
// Show clipboard actions menu with updated selection
if lp.device.is_touch() {
let has_selection = !self.selected_text().is_empty();
let selection_rect = self.get_selection_rect(cx);
cx.show_clipboard_actions(has_selection, selection_rect, cx.keyboard_shift);
}
// Skip next move to prevent selection change when finger lifts
self.ignore_next_move = true;
}
Hit::FingerMove(FingerMoveEvent {
abs,
tap_count,
device,
..
}) if device.is_primary_hit() && !scrollbar_captured => {
// Skip first move after long press to prevent selection changes
if self.ignore_next_move {
self.ignore_next_move = false;
return;
}
// Clear preserved cursor - user is dragging to select
self.preserved_selection_cursor = None;
self.reset_blink_timer(cx);
self.set_key_focus(cx);
let rel = abs - self.text_area.rect(cx).pos;
let Ok(cursor) =
self.point_in_lpxs_to_cursor(Point::new(rel.x as f32, rel.y as f32))
else {
warning!("can't move cursor because layout was invalidated by earlier event");
return;
};
self.set_cursor(cx, cursor, true);
match tap_count {
2 => self.select_word(cx),
3 => self.select_all(cx),
_ => {}
}
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::ReturnKey | KeyCode::NumpadEnter,
modifiers: mods @ KeyModifiers { shift: false, .. },
..
}) => {
// Decide whether this Enter press should submit or insert a newline:
// * Single-line inputs always submit.
// * Primary modifier (Cmd/Ctrl) + Enter always submits. A modifier
// only comes from a physical keyboard, so this stays ungated and
// keeps working even if keyboard detection lags.
// * Plain Enter submits only with a physical keyboard when
// submit_on_enter is set; a soft keyboard's multiline Enter
// always inserts a newline (the user submits via a button).
// In multiline mode, other modifier combos (Alt+Enter, or Ctrl+Enter
// on macOS) insert a newline below when not read-only.
let has_physical_keyboard = cx.keyboard.has_physical_keyboard();
let should_submit = !self.is_multiline
|| mods.is_primary()
|| (has_physical_keyboard && self.submit_on_enter && !mods.any());
if should_submit {
cx.hide_text_ime();
cx.set_key_focus(Area::Empty);
self.emit_return(cx, uid, mods);
} else if !self.is_read_only {
self.reset_blink_timer(cx);
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit(
cx,
Edit {
start: self.selection.start().index,
end: self.selection.end().index,
replace_with: "\n".to_string(),
},
);
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::Escape,
..
}) => {
cx.widget_action(uid, TextInputAction::Escaped);
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::Insert,
modifiers,
..
}) if !modifiers.any() => {
self.reset_blink_timer(cx);
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::ReturnKey | KeyCode::NumpadEnter,
modifiers: mods @ KeyModifiers { shift: true, .. },
..
}) if !self.is_read_only => {
if !self.is_multiline {
// Single-line fields submit on Enter regardless of Shift; never embed
// a raw newline.
cx.hide_text_ime();
cx.set_key_focus(Area::Empty);
self.emit_return(cx, uid, mods);
} else {
self.reset_blink_timer(cx);
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit(
cx,
Edit {
start: self.selection.start().index,
end: self.selection.end().index,
replace_with: "\n".to_string(),
},
);
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::Backspace,
modifiers,
..
}) if !self.is_read_only => {
self.reset_blink_timer(cx);
let (start, end) = self.backspace_range(modifiers);
self.create_or_extend_edit_group(EditKind::Backspace);
self.apply_edit(
cx,
Edit {
start,
end,
replace_with: String::new(),
},
);
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::Delete,
modifiers,
..
}) if !self.is_read_only => {
self.reset_blink_timer(cx);
let (start, end) = self.delete_range(modifiers);
self.create_or_extend_edit_group(EditKind::Delete);
self.apply_edit(
cx,
Edit {
start,
end,
replace_with: String::new(),
},
);
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::KeyZ,
modifiers: modifiers @ KeyModifiers { shift: false, .. },
..
}) if modifiers.is_primary() && !self.is_read_only => {
if !self.undo(cx) {
return;
}
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
Hit::KeyDown(KeyEvent {
key_code: KeyCode::KeyZ,
modifiers: modifiers @ KeyModifiers { shift: true, .. },
..
}) if modifiers.is_primary() && !self.is_read_only => {
if !self.redo(cx) {
return;
}
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
Hit::TextInput(event) if !self.is_read_only => {
// Text changes invalidate any preserved cursor from a pending tap gesture
self.preserved_selection_cursor = None;
self.pending_outside_focus_loss_touch = None;
// Full state sync (authoritative: Android InputConnection / iOS UITextView)
if let Some(full_state) = &event.full_state_sync {
// The view can deliver characters a restricted field disallows (paste,
// hardware keyboard); filter so iOS/Android match every other platform.
// A selection-only sync (text unchanged) skips the filter pass + its
// per-keystroke allocation.
let (text_changed, rejected) = if self.text == full_state.text {
(false, false)
} else {
let filtered = self.filter_input(&full_state.text, true);
let rejected = filtered != full_state.text;
let changed = self.text != filtered;
if changed {
// Apply only the changed middle span so undo records a small
// delta; a whole-text replace would clone the entire old text
// onto the undo stack on every keystroke.
let common_prefix = self
.text
.chars()
.zip(filtered.chars())
.take_while(|(a, b)| a == b)
.count();
let old_count = self.text.chars().count();
let new_count = filtered.chars().count();
let max_suffix = old_count.min(new_count) - common_prefix;
let common_suffix = self
.text
.chars()
.rev()
.zip(filtered.chars().rev())
.take_while(|(a, b)| a == b)
.count()
.min(max_suffix);
let start = CharOffset(common_prefix).to_byte_index(&self.text);
let old_end =
CharOffset(old_count - common_suffix).to_byte_index(&self.text);
let new_end =
CharOffset(new_count - common_suffix).to_byte_index(&filtered);
self.create_or_extend_edit_group(EditKind::Other);
// history.apply_edit records the inverse + rewrites self.text; the
// selection is set authoritatively below, so skip the wasted
// selection transform apply_edit_preserving_selection would do.
self.history.apply_edit(
Edit {
start,
end: old_end,
replace_with: filtered[start..new_end].to_string(),
},
&mut self.text,
);
self.laidout_text = None;
}
(changed, rejected)
};
// The view's selection offsets index the UNFILTERED full_state.text.
// When chars were rejected, self.text is now shorter, so map each
// endpoint through the filter (count filtered chars in the prefix)
// before resolving to a byte index; otherwise self.text == the source
// text and the offsets map directly.
let remap_char_offset = |this: &Self, offset: CharOffset| -> usize {
let char_idx = if rejected {
// self.text is the filtered subsequence of full_state.text; count
// the kept chars falling at or before this source offset (greedy
// match), which avoids re-filtering a prefix.
let mut kept = this.text.chars();
let mut next_kept = kept.next();
let mut count = 0;
for (i, c) in full_state.text.chars().enumerate() {
if i >= offset.0 {
break;
}
if Some(c) == next_kept {
count += 1;
next_kept = kept.next();
}
}
count
} else {
offset.0
};
floor_grapheme_boundary(
&this.text,
CharOffset(char_idx).to_byte_index(&this.text),
)
};
let sel_start_byte = remap_char_offset(self, full_state.selection.start);
let sel_end_byte = remap_char_offset(self, full_state.selection.end);
self.needs_scroll_to_cursor = true;
self.selection = Selection {
anchor: Cursor {
index: sel_start_byte,
prefer_next_row: false,
},
cursor: Cursor {
index: sel_end_byte,
prefer_next_row: false,
},
};
if let Some(composition_range) = &full_state.composition {
// Same filter remap as the selection: when chars were rejected the
// composition offsets index the unfiltered source, so map them onto
// the filtered self.text (remap_char_offset also grapheme-floors).
self.composition_start = remap_char_offset(self, composition_range.start);
self.composition_end = remap_char_offset(self, composition_range.end);
} else {
self.composition_start = 0;
self.composition_end = 0;
}
if rejected && self.has_composition() {
// update_ime_context skips the re-push while composing, so push the
// cleaned text (with the remapped composition) directly so the view
// drops the rejected chars before the composition commits.
let sel = CharOffset(self.text[..sel_start_byte].chars().count())
..CharOffset(self.text[..sel_end_byte].chars().count());
let comp = CharOffset(self.text[..self.composition_start].chars().count())
..CharOffset(self.text[..self.composition_end].chars().count());
self.last_sent_ime_text = self.text.clone();
self.last_sent_ime_sel_start = sel_start_byte;
self.last_sent_ime_sel_end = sel_end_byte;
self.ime_update_frame = cx.redraw_id();
cx.sync_ime_state(self.text.clone(), sel, Some(comp));
} else if rejected {
// The view still holds the rejected chars; force update_ime_context
// to re-push the cleaned text back to it (sentinel selection).
self.last_sent_ime_sel_start = usize::MAX;
self.last_sent_ime_sel_end = usize::MAX;
} else {
self.last_sent_ime_text = self.text.clone();
self.last_sent_ime_sel_start = sel_start_byte;
self.last_sent_ime_sel_end = sel_end_byte;
self.ime_update_frame = cx.redraw_id();
}
// This path bypasses apply_edit(), so keep placeholder/color state in sync.
self.check_text_is_empty(cx);
self.draw_bg.redraw(cx);
if text_changed {
self.emit_change(cx, uid);
cx.hide_clipboard_actions();
}
return;
}
// Handle iOS range replacement (autocorrect/paste)
if let Some((start, end)) = event.replace_range {
let filtered_text = self.filter_input(&event.input, false);
if filtered_text.is_empty() && !event.input.is_empty() {
self.update_ime_context(cx);
return;
}
let byte_start = start.to_byte_index(&self.text);
let byte_end = end.to_byte_index(&self.text);
let (byte_start, byte_end) =
(byte_start.min(byte_end), byte_start.max(byte_end));
self.composition_start = 0;
self.composition_end = 0;
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit_preserving_selection(
cx,
Edit {
start: byte_start,
end: byte_end,
replace_with: filtered_text,
},
);
self.ime_update_frame = cx.redraw_id();
self.animator_play(cx, ids!(empty.off));
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
cx.hide_clipboard_actions();
return;
}
// Handle regular text input and composition (all platforms)
let input = self.filter_input(&event.input, false);
if input.is_empty() {
// Composition cancelled, remove preview text
if event.replace_last && self.has_composition() {
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit(
cx,
Edit {
start: self.composition_start.min(self.text.len()),
end: self.composition_end.min(self.text.len()),
replace_with: String::new(),
},
);
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
self.composition_end = self.composition_start;
return;
}
if event.replace_last {
// IME composition preview
if self.has_composition() {
let start = self.composition_start.min(self.text.len());
let end = self.composition_end.min(self.text.len());
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit(
cx,
Edit {
start,
end,
replace_with: input.clone(),
},
);
self.composition_end = self.composition_start + input.len();
} else {
self.composition_start = self.selection.start().index;
self.composition_end = self.composition_start + input.len();
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit(
cx,
Edit {
start: self.selection.start().index,
end: self.selection.end().index,
replace_with: input,
},
);
}
self.ime_update_frame = cx.redraw_id();
} else {
// Final commit or regular text input
if self.has_composition() {
let start = self.composition_start.min(self.text.len());
let end = self.composition_end.min(self.text.len());
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit(
cx,
Edit {
start,
end,
replace_with: input,
},
);
self.composition_end = self.composition_start;
} else {
self.create_or_extend_edit_group(if event.was_paste {
EditKind::Other
} else {
EditKind::Insert
});
self.apply_edit(
cx,
Edit {
start: self.selection.start().index,
end: self.selection.end().index,
replace_with: input,
},
);
}
}
self.animator_play(cx, ids!(empty.off));
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
cx.hide_clipboard_actions();
}
Hit::TextRangeReplace(event) if !self.is_read_only => {
self.pending_outside_focus_loss_touch = None;
// iOS autocorrect sends range replacement events
let filtered_text = self.filter_input(&event.text, false);
if filtered_text.is_empty() && !event.text.is_empty() {
self.update_ime_context(cx);
return;
}
// Convert character indices to byte indices
let (byte_start, byte_end) =
self.char_range_to_byte_range(event.start, event.end);
let resolved_range = self.resolve_external_replace_range(
byte_start,
byte_end,
event.replaced_text.as_deref(),
);
match resolved_range {
Some((byte_start, byte_end)) => {
self.composition_start = 0;
self.composition_end = 0;
self.create_or_extend_edit_group(EditKind::Other);
self.apply_edit_preserving_selection(
cx,
Edit {
start: byte_start,
end: byte_end,
replace_with: filtered_text,
},
);
}
None if event.fallback_to_insert => {
self.composition_start = 0;
self.composition_end = 0;
self.create_or_extend_edit_group(if filtered_text.is_empty() {
EditKind::Other
} else {
EditKind::Insert
});
self.apply_edit(
cx,
Edit {
start: self.selection.start().index,
end: self.selection.end().index,
replace_with: filtered_text,
},
);
}
None => {
self.last_sent_ime_sel_start = usize::MAX;
self.update_ime_context(cx);
return;
}
}
self.ime_update_frame = cx.redraw_id();
self.animator_play(cx, ids!(empty.off));
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
cx.hide_clipboard_actions();
}
Hit::TextCopy(event) => {
*event.response.borrow_mut() = Some(self.selected_text().to_string());
}
Hit::TextCut(event) => {
*event.response.borrow_mut() = Some(self.selected_text().to_string());
if !self.selected_text().is_empty() {
self.history
.create_or_extend_edit_group(EditKind::Other, self.selection);
self.apply_edit(
cx,
Edit {
start: self.selection.start().index,
end: self.selection.end().index,
replace_with: String::new(),
},
);
self.draw_bg.redraw(cx);
self.emit_change(cx, uid);
}
}
Hit::ImeAction(event) => {
use crate::makepad_platform::event::ImeAction;
let mods = KeyModifiers::default();
match event.action {
ImeAction::Done | ImeAction::Go | ImeAction::Search | ImeAction::Send => {
cx.hide_text_ime();
cx.set_key_focus(Area::Empty);
self.emit_return(cx, uid, mods);
}
ImeAction::Next | ImeAction::Previous => {
self.emit_return(cx, uid, mods);
}
ImeAction::Unspecified | ImeAction::None => {}
}
}
Hit::KeyDown(event) => {
cx.widget_action(uid, TextInputAction::KeyDownUnhandled(event));
}
_ => {}
}
}
}
impl TextInputRef {
/// See [`TextInput::set_max_lines`].
pub fn set_max_lines(&self, cx: &mut Cx, max_lines: usize) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_max_lines(cx, max_lines);
}
}
pub fn max_lines(&self) -> usize {
self.borrow().map(|inner| inner.max_lines()).unwrap_or(0)
}
/// See [`TextInput::take_key_focus`].
pub fn take_key_focus(&self, cx: &mut Cx) {
if let Some(mut inner) = self.borrow_mut() {
inner.take_key_focus(cx);
}
}
/// See [`TextInput::set_height`].
pub fn set_height(&self, cx: &mut Cx, height: Size) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_height(cx, height);
}
}
/// See [`TextInput::scroll_to_top`].
pub fn scroll_to_top(&self, cx: &mut Cx) {
if let Some(mut inner) = self.borrow_mut() {
inner.scroll_to_top(cx);
}
}
pub fn is_multiline(&self) -> bool {
if let Some(inner) = self.borrow() {
inner.is_multiline()
} else {
false
}
}
pub fn set_is_multiline(&self, cx: &mut Cx, is_multiline: bool) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_is_multiline(cx, is_multiline);
}
}
/// Returns whether this (multiline) text input emits `Returned` on plain
/// Enter. See [`TextInput::submit_on_enter`] for details.
pub fn submit_on_enter(&self) -> bool {
self.borrow()
.map(|inner| inner.submit_on_enter())
.unwrap_or(false)
}
/// Sets whether this (multiline) text input emits `Returned` on plain
/// Enter. See [`TextInput::submit_on_enter`] for details.
pub fn set_submit_on_enter(&self, submit_on_enter: bool) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_submit_on_enter(submit_on_enter);
}
}
pub fn is_password(&self) -> bool {
if let Some(inner) = self.borrow() {
inner.is_password()
} else {
false
}
}
pub fn set_is_password(&self, cx: &mut Cx, is_password: bool) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_is_password(cx, is_password);
}
}
pub fn toggle_is_password(&self, cx: &mut Cx) {
if let Some(mut inner) = self.borrow_mut() {
inner.toggle_is_password(cx);
}
}
pub fn is_read_only(&self) -> bool {
if let Some(inner) = self.borrow() {
inner.is_read_only()
} else {
false
}
}
pub fn set_is_read_only(&self, cx: &mut Cx, is_read_only: bool) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_is_read_only(cx, is_read_only);
}
}
pub fn toggle_is_read_only(&self, cx: &mut Cx) {
if let Some(mut inner) = self.borrow_mut() {
inner.toggle_is_read_only(cx);
}
}
pub fn is_numeric_only(&self) -> bool {
if let Some(inner) = self.borrow() {
inner.is_numeric_only()
} else {
false
}
}
pub fn set_is_numeric_only(&self, cx: &mut Cx, is_numeric_only: bool) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_is_numeric_only(cx, is_numeric_only);
}
}
pub fn toggle_is_numeric_only(&self, cx: &mut Cx) {
if let Some(mut inner) = self.borrow_mut() {
inner.toggle_is_numeric_only(cx);
}
}
pub fn empty_text(&self) -> String {
if let Some(inner) = self.borrow() {
inner.empty_text().to_string()
} else {
String::new()
}
}
pub fn set_empty_text(&self, cx: &mut Cx, empty_text: String) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_empty_text(cx, empty_text);
}
}
pub fn selection(&self) -> Selection {
if let Some(inner) = self.borrow() {
inner.selection()
} else {
Default::default()
}
}
pub fn set_selection(&self, cx: &mut Cx, selection: Selection) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_selection(cx, selection);
}
}
pub fn cursor(&self) -> Cursor {
if let Some(inner) = self.borrow() {
inner.cursor()
} else {
Default::default()
}
}
/// The caret's rectangle in absolute screen coordinates, or `None` if the
/// text hasn't been laid out yet. Useful for anchoring a popup to the cursor.
pub fn cursor_rect_in_absolute(&self, cx: &Cx) -> Option<Rect> {
self.borrow().and_then(|inner| inner.cursor_rect_in_absolute(cx))
}
pub fn set_cursor(&self, cx: &mut Cx, cursor: Cursor, keep_selection: bool) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_cursor(cx, cursor, keep_selection);
}
}
pub fn selected_text(&self) -> String {
if let Some(inner) = self.borrow() {
inner.selected_text().to_string()
} else {
String::new()
}
}
pub fn returned(&self, actions: &Actions) -> Option<(String, KeyModifiers)> {
for action in actions.filter_widget_actions_cast::<TextInputAction>(self.widget_uid()) {
if let TextInputAction::Returned(text, modifiers) = action {
return Some((text, modifiers));
}
}
None
}
pub fn escaped(&self, actions: &Actions) -> bool {
for action in actions.filter_widget_actions_cast::<TextInputAction>(self.widget_uid()) {
if let TextInputAction::Escaped = action {
return true;
}
}
false
}
/// Returns true if this TextInput lost keyboard focus in the given actions.
pub fn key_focus_lost(&self, actions: &Actions) -> bool {
for action in actions.filter_widget_actions_cast::<TextInputAction>(self.widget_uid()) {
if let TextInputAction::KeyFocusLost = action {
return true;
}
}
false
}
pub fn changed(&self, actions: &Actions) -> Option<String> {
for action in actions.filter_widget_actions_cast::<TextInputAction>(self.widget_uid()) {
if let TextInputAction::Changed(text) = action {
return Some(text);
}
}
None
}
pub fn key_down_unhandled(&self, actions: &Actions) -> Option<KeyEvent> {
for action in actions.filter_widget_actions_cast::<TextInputAction>(self.widget_uid()) {
if let TextInputAction::KeyDownUnhandled(event) = action {
return Some(event);
}
}
None
}
/// Saves the internal state of this text input widget
/// to a new `TextInputState` object.
pub fn save_state(&self) -> TextInputState {
if let Some(inner) = self.borrow() {
TextInputState {
text: inner.text.clone(),
password_text: inner.password_text.clone(),
selection: inner.selection.clone(),
history: inner.history.clone(),
}
} else {
TextInputState::default()
}
}
/// Restores the internal state of this text input widget
/// from the given `TextInputState` object.
pub fn restore_state(&self, cx: &mut Cx, state: TextInputState) {
if let Some(mut inner) = self.borrow_mut() {
inner.set_text(cx, &state.text);
inner.password_text = state.password_text;
inner.history = state.history;
inner.set_selection(cx, state.selection);
}
}
}
/// The saved (checkpointed) state of a text input widget.
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TextInputState {
text: String,
password_text: String,
selection: Selection,
history: History,
}
#[derive(Clone, Debug, Default)]
pub enum TextInputAction {
#[default]
None,
KeyFocus,
KeyFocusLost,
Returned(String, KeyModifiers),
Escaped,
Changed(String),
KeyDownUnhandled(KeyEvent),
}
#[derive(Clone, Copy, Debug)]
enum TextNavigation {
Left,
Right,
WordLeft,
WordRight,
Up,
Down,
LineStart,
LineEnd,
TextStart,
TextEnd,
PageUp,
PageDown,
}
impl TextNavigation {
fn from_key_event(event: KeyEvent) -> Option<Self> {
let modifiers = event.modifiers;
if has_only_selection_modifier(modifiers) {
return match event.key_code {
KeyCode::ArrowLeft => Some(Self::Left),
KeyCode::ArrowRight => Some(Self::Right),
KeyCode::ArrowUp => Some(Self::Up),
KeyCode::ArrowDown => Some(Self::Down),
KeyCode::Home => Some(Self::LineStart),
KeyCode::End => Some(Self::LineEnd),
KeyCode::PageUp => Some(Self::PageUp),
KeyCode::PageDown => Some(Self::PageDown),
_ => None,
};
}
if is_word_modifier(modifiers) {
match event.key_code {
KeyCode::ArrowLeft => return Some(Self::WordLeft),
KeyCode::ArrowRight => return Some(Self::WordRight),
_ => {}
}
}
if is_text_boundary_modifier(modifiers) {
let uses_apple_text_boundary = uses_apple_text_boundary_modifier(modifiers);
return match event.key_code {
KeyCode::Home => Some(Self::TextStart),
KeyCode::End => Some(Self::TextEnd),
KeyCode::ArrowUp if uses_apple_text_boundary => Some(Self::TextStart),
KeyCode::ArrowDown if uses_apple_text_boundary => Some(Self::TextEnd),
KeyCode::ArrowLeft if uses_apple_text_boundary => Some(Self::LineStart),
KeyCode::ArrowRight if uses_apple_text_boundary => Some(Self::LineEnd),
_ => None,
};
}
None
}
}
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct History {
current_edit_kind: Option<EditKind>,
undo_stack: EditStack,
redo_stack: EditStack,
}
impl History {
fn force_new_edit_group(&mut self) {
self.current_edit_kind = None;
}
fn create_or_extend_edit_group(&mut self, edit_kind: EditKind, selection: Selection) {
if !self.current_edit_kind.map_or(false, |current_edit_kind| {
current_edit_kind.can_merge_with(edit_kind)
}) {
self.undo_stack.push_edit_group(selection);
self.current_edit_kind = Some(edit_kind);
}
}
fn apply_edit(&mut self, edit: Edit, text: &mut String) {
let inverted_edit = edit.invert(&text);
edit.apply(text);
self.undo_stack.push_edit(inverted_edit);
self.redo_stack.clear();
}
fn undo(&mut self, selection: Selection, text: &mut String) -> Option<Selection> {
if let Some((new_selection, edits)) = self.undo_stack.pop_edit_group() {
self.redo_stack.push_edit_group(selection);
for edit in &edits {
let inverted_edit = edit.invert(text);
edit.apply(text);
self.redo_stack.push_edit(inverted_edit);
}
self.current_edit_kind = None;
Some(new_selection)
} else {
None
}
}
fn redo(&mut self, selection: Selection, text: &mut String) -> Option<Selection> {
if let Some((new_selection, edits)) = self.redo_stack.pop_edit_group() {
self.undo_stack.push_edit_group(selection);
for edit in &edits {
let inverted_edit = edit.invert(text);
edit.apply(text);
self.undo_stack.push_edit(inverted_edit);
}
self.current_edit_kind = None;
Some(new_selection)
} else {
None
}
}
fn clear(&mut self) {
self.current_edit_kind = None;
self.undo_stack.clear();
self.redo_stack.clear();
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
enum EditKind {
Insert,
Backspace,
Delete,
Other,
}
impl EditKind {
fn can_merge_with(self, other: EditKind) -> bool {
if self == Self::Other {
false
} else {
self == other
}
}
}
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct EditStack {
edit_groups: Vec<EditGroup>,
edits: Vec<Edit>,
}
impl EditStack {
fn push_edit_group(&mut self, selection: Selection) {
self.edit_groups.push(EditGroup {
selection,
edit_start: self.edits.len(),
});
}
fn push_edit(&mut self, edit: Edit) {
self.edits.push(edit);
}
fn pop_edit_group(&mut self) -> Option<(Selection, Vec<Edit>)> {
match self.edit_groups.pop() {
Some(edit_group) => Some((
edit_group.selection,
self.edits.drain(edit_group.edit_start..).rev().collect(),
)),
None => None,
}
}
fn clear(&mut self) {
self.edit_groups.clear();
self.edits.clear();
}
}
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct EditGroup {
selection: Selection,
edit_start: usize,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
struct Edit {
start: usize,
end: usize,
replace_with: String,
}
impl Edit {
fn apply(&self, text: &mut String) {
text.replace_range(self.start..self.end, &self.replace_with);
}
fn invert(&self, text: &str) -> Self {
Self {
start: self.start,
end: self.start + self.replace_with.len(),
replace_with: text[self.start..self.end].to_string(),
}
}
}
fn prev_grapheme_boundary(text: &str, index: usize) -> usize {
let index = floor_char_boundary(text, index);
let floor = floor_grapheme_boundary(text, index);
if floor != index {
return floor;
}
let mut cursor = GraphemeCursor::new(index, text.len(), true);
cursor.prev_boundary(text, 0).unwrap().unwrap_or(0)
}
fn next_grapheme_boundary(text: &str, index: usize) -> usize {
let index = floor_char_boundary(text, index);
let ceil = ceil_grapheme_boundary(text, index);
if ceil != index {
return ceil;
}
let mut cursor = GraphemeCursor::new(index, text.len(), true);
cursor.next_boundary(text, 0).unwrap().unwrap_or(text.len())
}
fn floor_grapheme_boundary(text: &str, index: usize) -> usize {
let index = floor_char_boundary(text, index);
if index == text.len() {
return index;
}
let mut boundary = 0;
for (next_boundary, _) in text.grapheme_indices(true) {
if next_boundary > index {
break;
}
boundary = next_boundary;
}
boundary
}
fn ceil_grapheme_boundary(text: &str, index: usize) -> usize {
let index = floor_char_boundary(text, index);
if index == text.len() {
return index;
}
for (boundary, _) in text.grapheme_indices(true) {
if boundary >= index {
return boundary;
}
}
text.len()
}
fn prev_word_boundary(text: &str, index: usize) -> usize {
let index = floor_char_boundary(text, index);
let mut previous_word_start = 0;
for (start, word) in text.unicode_word_indices() {
let end = start + word.len();
if end >= index {
if start < index {
return start;
}
return previous_word_start;
}
previous_word_start = start;
}
previous_word_start
}
fn next_word_boundary(text: &str, index: usize) -> usize {
let index = floor_char_boundary(text, index);
for (start, word) in text.unicode_word_indices() {
let end = start + word.len();
if end > index || start >= index {
return end;
}
}
text.len()
}
fn floor_char_boundary(text: &str, mut index: usize) -> usize {
index = index.min(text.len());
while !text.is_char_boundary(index) {
index -= 1;
}
index
}
fn has_only_selection_modifier(modifiers: KeyModifiers) -> bool {
!modifiers.control && !modifiers.alt && !modifiers.logo
}
fn is_word_modifier(modifiers: KeyModifiers) -> bool {
if cfg!(target_arch = "wasm32") {
return (modifiers.alt ^ modifiers.control) && !modifiers.logo;
}
if is_apple_text_platform() {
modifiers.alt && !modifiers.control && !modifiers.logo
} else {
modifiers.control && !modifiers.alt && !modifiers.logo
}
}
fn is_text_boundary_modifier(modifiers: KeyModifiers) -> bool {
if cfg!(target_arch = "wasm32") {
return !modifiers.alt && (modifiers.logo ^ modifiers.control);
}
if is_apple_text_platform() {
modifiers.logo && !modifiers.control && !modifiers.alt
} else {
modifiers.control && !modifiers.alt && !modifiers.logo
}
}
fn is_line_delete_modifier(modifiers: KeyModifiers) -> bool {
uses_apple_text_boundary_modifier(modifiers)
}
fn uses_apple_text_boundary_modifier(modifiers: KeyModifiers) -> bool {
(is_apple_text_platform() && modifiers.logo && !modifiers.control && !modifiers.alt)
|| (cfg!(target_arch = "wasm32")
&& modifiers.logo
&& !modifiers.control
&& !modifiers.alt)
}
fn is_apple_text_platform() -> bool {
cfg!(target_vendor = "apple")
}