* 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.
1319 lines
45 KiB
Rust
1319 lines
45 KiB
Rust
use {
|
|
crate::makepad_draw::event::FingerLongPressEvent,
|
|
crate::{
|
|
animator::*,
|
|
makepad_derive_widget::*,
|
|
makepad_draw::*,
|
|
makepad_script::{script_err_wrong_value, ScriptFnRef},
|
|
scroll_bars::ScrollBars,
|
|
widget::*,
|
|
widget_async::{
|
|
CxWidgetToScriptCallExt, ScriptAsyncCalls, ScriptAsyncId, ScriptAsyncResult,
|
|
},
|
|
widget_tree::CxWidgetExt,
|
|
},
|
|
};
|
|
|
|
script_mod! {
|
|
use mod.prelude.widgets_internal.*
|
|
|
|
mod.widgets.ViewBase = set_type_default() do #(View::register_widget(vm))
|
|
}
|
|
|
|
// maybe we should put an enum on the bools like
|
|
|
|
#[derive(Script, ScriptHook, Clone, Copy)]
|
|
pub enum ViewOptimize {
|
|
#[pick]
|
|
None,
|
|
DrawList,
|
|
Texture,
|
|
}
|
|
|
|
impl Default for ViewOptimize {
|
|
fn default() -> Self {
|
|
Self::None
|
|
}
|
|
}
|
|
|
|
#[derive(Script, ScriptHook)]
|
|
pub enum EventOrder {
|
|
Down,
|
|
#[pick]
|
|
Up,
|
|
#[live(Default::default())]
|
|
List(Vec<LiveId>),
|
|
}
|
|
|
|
impl ViewOptimize {
|
|
fn is_texture(&self) -> bool {
|
|
if let Self::Texture = self {
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
fn is_draw_list(&self) -> bool {
|
|
if let Self::DrawList = self {
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
fn needs_draw_list(&self) -> bool {
|
|
return self.is_texture() || self.is_draw_list();
|
|
}
|
|
}
|
|
|
|
#[derive(Script, Animator, WidgetRef, WidgetSet, WidgetRegister)]
|
|
pub struct View {
|
|
#[uid]
|
|
uid: WidgetUid,
|
|
#[source]
|
|
pub source: ScriptObjectRef,
|
|
// draw info per UI element
|
|
#[live]
|
|
pub draw_bg: DrawQuad,
|
|
|
|
#[live(false)]
|
|
pub show_bg: bool,
|
|
|
|
#[layout]
|
|
pub layout: Layout,
|
|
|
|
#[walk]
|
|
pub walk: Walk,
|
|
|
|
//#[live] use_cache: bool,
|
|
#[live]
|
|
dpi_factor: Option<f64>,
|
|
|
|
#[live(false)]
|
|
pub new_batch: bool,
|
|
|
|
#[live(false)]
|
|
pub texture_caching: bool,
|
|
|
|
#[rust]
|
|
optimize: ViewOptimize,
|
|
#[live]
|
|
event_order: EventOrder,
|
|
|
|
#[live(true)]
|
|
pub visible: bool,
|
|
#[live(false)]
|
|
skip_widget_tree_search: bool,
|
|
|
|
#[live(true)]
|
|
grab_key_focus: bool,
|
|
#[live(false)]
|
|
block_signal_event: bool,
|
|
#[live]
|
|
pub cursor: Option<MouseCursor>,
|
|
#[live(false)]
|
|
capture_overload: bool,
|
|
#[live]
|
|
scroll_bars: ScriptObjectRef,
|
|
|
|
#[live(false)]
|
|
design_mode: bool,
|
|
|
|
#[live(false)]
|
|
pub debug_stream: bool,
|
|
|
|
#[live]
|
|
on_render: ScriptFnRef,
|
|
|
|
#[rust]
|
|
script_async: ScriptAsyncCalls,
|
|
|
|
#[rust]
|
|
scroll_bars_obj: Option<Box<ScrollBars>>,
|
|
#[rust]
|
|
view_size: Option<Vec2d>,
|
|
// Forces the next Texture-mode draw to re-render its offscreen pass instead of cache-hitting.
|
|
// Set on a repopulate or optimize-mode flip, since neither moves the rect that will_redraw sees.
|
|
#[rust]
|
|
force_texture_redraw: bool,
|
|
// Caps the offscreen texture's height (in Texture mode only) so a huge Fit-height view never
|
|
// allocates a render target past the GPU's max texture size. Content past the cap is clipped.
|
|
#[rust]
|
|
texture_max_height: Option<f64>,
|
|
|
|
#[rust]
|
|
area: Area,
|
|
#[rust]
|
|
draw_list: Option<DrawList2d>,
|
|
|
|
#[rust]
|
|
texture_cache: Option<ViewTextureCache>,
|
|
#[rust]
|
|
defer_walks: SmallVec<[(LiveId, DeferredWalk); 1]>,
|
|
#[rust]
|
|
draw_state: DrawStateWrap<DrawState>,
|
|
#[rust]
|
|
pub children: SmallVec<[(LiveId, WidgetRef); 2]>,
|
|
#[rust]
|
|
live_update_order: SmallVec<[LiveId; 1]>,
|
|
|
|
#[apply_default]
|
|
animator: Animator,
|
|
}
|
|
|
|
struct ViewTextureCache {
|
|
pass: DrawPass,
|
|
_depth_texture: Texture,
|
|
color_texture: Texture,
|
|
}
|
|
|
|
impl ScriptHook for View {
|
|
fn on_before_apply(
|
|
&mut self,
|
|
_vm: &mut ScriptVm,
|
|
apply: &Apply,
|
|
_scope: &mut Scope,
|
|
_value: ScriptValue,
|
|
) {
|
|
if apply.is_reload() {
|
|
self.live_update_order.clear();
|
|
}
|
|
}
|
|
|
|
fn on_after_apply(
|
|
&mut self,
|
|
vm: &mut ScriptVm,
|
|
apply: &Apply,
|
|
scope: &mut Scope,
|
|
value: ScriptValue,
|
|
) {
|
|
// Handle children from the object's vec
|
|
// Skip for eval applies - eval should only affect this widget's own properties,
|
|
// not propagate to children (which would use inherited vec from prototype)
|
|
if !apply.is_eval() {
|
|
if let Some(obj) = value.as_object() {
|
|
let mut anon_index = 0usize;
|
|
vm.vec_with(obj, |vm, vec| {
|
|
for kv in vec {
|
|
// Determine the id: use prefixed id if available, otherwise use numbered id for anonymous children
|
|
let id = if let Some(id) = kv.key.as_id() {
|
|
Some(id)
|
|
} else if kv.key.is_nil() {
|
|
// Anonymous child widget - use numbered id
|
|
let id = LiveId(anon_index as u64);
|
|
anon_index += 1;
|
|
Some(id)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(id) = id {
|
|
if !WidgetRef::value_is_newable_widget(vm, kv.value) {
|
|
continue;
|
|
}
|
|
|
|
if apply.is_reload() {
|
|
self.live_update_order.push(id);
|
|
}
|
|
|
|
if let Some((_, node)) =
|
|
self.children.iter_mut().find(|(id2, _)| *id2 == id)
|
|
{
|
|
node.script_apply(vm, apply, scope, kv.value);
|
|
} else {
|
|
let widget =
|
|
WidgetRef::script_from_value_scoped(vm, scope, kv.value);
|
|
self.children.push((id, widget));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if apply.is_reload() {
|
|
// update/delete children list
|
|
// Only reorder and truncate if we actually processed items from the vec.
|
|
// If vec was empty but children exist, this is likely an incomplete parse
|
|
// during streaming - preserve existing children.
|
|
if !self.live_update_order.is_empty() || self.children.is_empty() {
|
|
for (idx, id) in self.live_update_order.iter().enumerate() {
|
|
if let Some(pos) = self.children.iter().position(|(i, _v)| *i == *id) {
|
|
self.children.swap(idx, pos);
|
|
}
|
|
}
|
|
self.children.truncate(self.live_update_order.len());
|
|
}
|
|
}
|
|
|
|
if self.texture_caching {
|
|
self.optimize = ViewOptimize::Texture;
|
|
} else if self.new_batch {
|
|
self.optimize = ViewOptimize::DrawList;
|
|
}
|
|
|
|
if self.optimize.needs_draw_list() && self.draw_list.is_none() {
|
|
self.draw_list = Some(DrawList2d::script_new(vm));
|
|
}
|
|
if !self.scroll_bars.is_zero() {
|
|
if self.scroll_bars_obj.is_none() {
|
|
self.scroll_bars_obj = Some(Box::new(ScrollBars::script_from_value(
|
|
vm,
|
|
self.scroll_bars.as_object().into(),
|
|
)));
|
|
}
|
|
}
|
|
|
|
vm.cx_mut().widget_tree_mark_dirty(self.uid);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub enum ViewAction {
|
|
#[default]
|
|
None,
|
|
FingerDown(FingerDownEvent),
|
|
FingerUp(FingerUpEvent),
|
|
FingerLongPress(FingerLongPressEvent),
|
|
FingerMove(FingerMoveEvent),
|
|
FingerHoverIn(FingerHoverEvent),
|
|
FingerHoverOut(FingerHoverEvent),
|
|
KeyDown(KeyEvent),
|
|
KeyUp(KeyEvent),
|
|
}
|
|
|
|
impl View {
|
|
/// Build the object `on_render` appends its children to. It is re-applied to this
|
|
/// View with `Apply::Reload`, so whatever isn't reachable on its prototype chain
|
|
/// reverts to the base widget's defaults.
|
|
///
|
|
/// It therefore protos off the *instance* source — the declaration site — not the
|
|
/// instance's template. Protoing off `source.proto` skipped the declaration level
|
|
/// and silently dropped every property set there: `results := ScrollYView{height: 610}`
|
|
/// re-applied as the base `height: Fill`, which a `Fit` parent defers to zero, so
|
|
/// the view (and every rendered row in it) vanished after the first `render()`.
|
|
///
|
|
/// `script_result` restores `self.source` afterwards, so the chain stays
|
|
/// `me -> declaration` instead of growing a link per render. `no_vec` leaves `me`'s
|
|
/// own vec empty so `on_render` fully owns the child list.
|
|
fn make_render_me(&self, vm: &mut ScriptVm) -> ScriptValue {
|
|
if self.source.is_zero() {
|
|
return NIL;
|
|
}
|
|
|
|
vm.bx
|
|
.heap
|
|
.new_with_proto_no_vec(self.source.as_object().into())
|
|
.into()
|
|
}
|
|
|
|
pub fn set_debug_dump(&mut self, cx: &mut Cx, debug: bool) {
|
|
if let Some(draw_list) = &self.draw_list {
|
|
cx.draw_lists[draw_list.id()].debug_dump = debug;
|
|
}
|
|
}
|
|
|
|
pub fn repaint(&mut self, cx: &mut Cx) {
|
|
if let Some(draw_list) = &self.draw_list {
|
|
if let Some(pass_id) = cx.draw_lists[draw_list.id()].draw_pass_id {
|
|
cx.repaint_pass(pass_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ViewRef {
|
|
pub fn set_debug_dump(&self, cx: &mut Cx, debug: bool) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_debug_dump(cx, debug);
|
|
}
|
|
}
|
|
|
|
pub fn repaint(&self, cx: &mut Cx) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.repaint(cx);
|
|
}
|
|
}
|
|
|
|
/// Forces the next Texture-mode draw to re-render its offscreen texture. Call after
|
|
/// repopulating a texture-cached view whose content changed.
|
|
pub fn redraw_texture_cache(&self) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.redraw_texture_cache();
|
|
}
|
|
}
|
|
|
|
/// Caps the offscreen texture's height in Texture mode (`None` = uncapped). See the `View`
|
|
/// method for details.
|
|
pub fn set_texture_max_height(&self, max: Option<f64>) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_texture_max_height(max);
|
|
}
|
|
}
|
|
|
|
pub fn finger_down(&self, actions: &Actions) -> Option<FingerDownEvent> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ViewAction::FingerDown(fd) = item.cast() {
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn finger_up(&self, actions: &Actions) -> Option<FingerUpEvent> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ViewAction::FingerUp(fd) = item.cast() {
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn finger_move(&self, actions: &Actions) -> Option<FingerMoveEvent> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ViewAction::FingerMove(fd) = item.cast() {
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn finger_hover_in(&self, actions: &Actions) -> Option<FingerHoverEvent> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ViewAction::FingerHoverIn(fd) = item.cast() {
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn finger_hover_out(&self, actions: &Actions) -> Option<FingerHoverEvent> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ViewAction::FingerHoverOut(fd) = item.cast() {
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn key_down(&self, actions: &Actions) -> Option<KeyEvent> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ViewAction::KeyDown(fd) = item.cast() {
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn key_up(&self, actions: &Actions) -> Option<KeyEvent> {
|
|
if let Some(item) = actions.find_widget_action(self.widget_uid()) {
|
|
if let ViewAction::KeyUp(fd) = item.cast() {
|
|
return Some(fd);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn animator_cut(&self, cx: &mut Cx, state: &[LiveId; 2]) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.animator_cut(cx, state);
|
|
}
|
|
}
|
|
|
|
pub fn animator_play(&self, cx: &mut Cx, state: &[LiveId; 2]) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.animator_play(cx, state);
|
|
}
|
|
}
|
|
|
|
pub fn animator_play_with(&self, cx: &mut Cx, state: &[LiveId; 2], play: Play) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.animator_play_with(cx, state, play);
|
|
}
|
|
}
|
|
|
|
pub fn toggle_state(
|
|
&self,
|
|
cx: &mut Cx,
|
|
is_state_1: bool,
|
|
animate: Animate,
|
|
state1: &[LiveId; 2],
|
|
state2: &[LiveId; 2],
|
|
) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.animator_toggle(cx, is_state_1, animate, state1, state2);
|
|
}
|
|
}
|
|
|
|
pub fn set_visible(&self, cx: &mut Cx, visible: bool) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_visible(cx, visible)
|
|
}
|
|
}
|
|
|
|
pub fn visible(&self) -> bool {
|
|
if let Some(inner) = self.borrow() {
|
|
inner.visible()
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn set_texture(&self, slot: usize, texture: &Texture) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.draw_bg.set_texture(slot, texture);
|
|
}
|
|
}
|
|
|
|
pub fn set_uniform(&self, cx: &Cx, uniform: LiveId, value: &[f32]) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.draw_bg.set_uniform(cx, uniform, value);
|
|
}
|
|
}
|
|
|
|
pub fn set_scroll_pos(&self, cx: &mut Cx, v: Vec2d) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_scroll_pos(cx, v)
|
|
}
|
|
}
|
|
|
|
pub fn area(&self) -> Area {
|
|
if let Some(inner) = self.borrow_mut() {
|
|
inner.area
|
|
} else {
|
|
Area::Empty
|
|
}
|
|
}
|
|
|
|
pub fn cached_texture_id(&self) -> Option<TextureId> {
|
|
self.borrow().and_then(|inner| {
|
|
inner
|
|
.texture_cache
|
|
.as_ref()
|
|
.map(|cache| cache.color_texture.texture_id())
|
|
})
|
|
}
|
|
|
|
pub fn child_count(&self) -> usize {
|
|
if let Some(inner) = self.borrow_mut() {
|
|
inner.children.len()
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
pub fn set_key_focus(&self, cx: &mut Cx) {
|
|
if let Some(inner) = self.borrow_mut() {
|
|
inner.set_key_focus(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ViewSet {
|
|
pub fn animator_cut(&self, cx: &mut Cx, state: &[LiveId; 2]) {
|
|
for item in self.iter() {
|
|
item.animator_cut(cx, state)
|
|
}
|
|
}
|
|
|
|
pub fn animator_play(&self, cx: &mut Cx, state: &[LiveId; 2]) {
|
|
for item in self.iter() {
|
|
item.animator_play(cx, state);
|
|
}
|
|
}
|
|
|
|
pub fn animator_play_with(&self, cx: &mut Cx, state: &[LiveId; 2], play: Play) {
|
|
for item in self.iter() {
|
|
item.animator_play_with(cx, state, play);
|
|
}
|
|
}
|
|
|
|
pub fn toggle_state(
|
|
&self,
|
|
cx: &mut Cx,
|
|
is_state_1: bool,
|
|
animate: Animate,
|
|
state1: &[LiveId; 2],
|
|
state2: &[LiveId; 2],
|
|
) {
|
|
for item in self.iter() {
|
|
item.toggle_state(cx, is_state_1, animate, state1, state2);
|
|
}
|
|
}
|
|
|
|
pub fn set_visible(&self, cx: &mut Cx, visible: bool) {
|
|
for item in self.iter() {
|
|
item.set_visible(cx, visible)
|
|
}
|
|
}
|
|
|
|
pub fn set_texture(&self, slot: usize, texture: &Texture) {
|
|
for item in self.iter() {
|
|
item.set_texture(slot, texture)
|
|
}
|
|
}
|
|
|
|
pub fn set_uniform(&self, cx: &Cx, uniform: LiveId, value: &[f32]) {
|
|
for item in self.iter() {
|
|
item.set_uniform(cx, uniform, value)
|
|
}
|
|
}
|
|
|
|
pub fn redraw(&self, cx: &mut Cx) {
|
|
for item in self.iter() {
|
|
item.redraw(cx);
|
|
}
|
|
}
|
|
|
|
pub fn finger_down(&self, actions: &Actions) -> Option<FingerDownEvent> {
|
|
for item in self.iter() {
|
|
if let Some(e) = item.finger_down(actions) {
|
|
return Some(e);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn finger_up(&self, actions: &Actions) -> Option<FingerUpEvent> {
|
|
for item in self.iter() {
|
|
if let Some(e) = item.finger_up(actions) {
|
|
return Some(e);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn finger_move(&self, actions: &Actions) -> Option<FingerMoveEvent> {
|
|
for item in self.iter() {
|
|
if let Some(e) = item.finger_move(actions) {
|
|
return Some(e);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn key_down(&self, actions: &Actions) -> Option<KeyEvent> {
|
|
for item in self.iter() {
|
|
if let Some(e) = item.key_down(actions) {
|
|
return Some(e);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn key_up(&self, actions: &Actions) -> Option<KeyEvent> {
|
|
for item in self.iter() {
|
|
if let Some(e) = item.key_up(actions) {
|
|
return Some(e);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
impl WidgetNode for View {
|
|
fn widget_uid(&self) -> WidgetUid {
|
|
self.uid
|
|
}
|
|
fn walk(&mut self, _cx: &mut Cx) -> Walk {
|
|
self.walk
|
|
}
|
|
|
|
fn area(&self) -> Area {
|
|
self.area
|
|
}
|
|
|
|
fn set_scroll_pos(&mut self, cx: &mut Cx, v: Vec2d) {
|
|
self.set_scroll_pos(cx, v);
|
|
}
|
|
|
|
fn redraw(&mut self, cx: &mut Cx) {
|
|
self.area.redraw(cx);
|
|
if let Some(draw_list) = &self.draw_list {
|
|
draw_list.redraw(cx);
|
|
}
|
|
for (_, child) in &mut self.children {
|
|
child.redraw(cx);
|
|
}
|
|
}
|
|
|
|
fn children(&self, visit: &mut dyn FnMut(LiveId, WidgetRef)) {
|
|
for (id, child) in &self.children {
|
|
visit(*id, child.clone());
|
|
}
|
|
}
|
|
|
|
fn skip_widget_tree_search(&self) -> bool {
|
|
self.skip_widget_tree_search
|
|
}
|
|
|
|
fn find_widgets_from_point(&self, cx: &Cx, point: DVec2, found: &mut dyn FnMut(&WidgetRef)) {
|
|
for (_, child) in &self.children {
|
|
child.find_widgets_from_point(cx, point, found);
|
|
}
|
|
}
|
|
|
|
fn selection_text_len(&self) -> usize {
|
|
for (_, child) in &self.children {
|
|
let v = child.selection_text_len();
|
|
if v > 0 {
|
|
return v;
|
|
}
|
|
}
|
|
0
|
|
}
|
|
|
|
fn selection_point_to_char_index(&self, cx: &Cx, abs: DVec2) -> Option<usize> {
|
|
for (_, child) in &self.children {
|
|
if let Some(v) = child.selection_point_to_char_index(cx, abs) {
|
|
return Some(v);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn selection_set(&mut self, anchor: usize, cursor: usize) {
|
|
for (_, child) in &self.children {
|
|
child.selection_set(anchor, cursor);
|
|
}
|
|
}
|
|
|
|
fn selection_clear(&mut self) {
|
|
for (_, child) in &self.children {
|
|
child.selection_clear();
|
|
}
|
|
}
|
|
|
|
fn selection_select_all(&mut self) {
|
|
for (_, child) in &self.children {
|
|
child.selection_select_all();
|
|
}
|
|
}
|
|
|
|
fn selection_get_text_for_range(&self, start: usize, end: usize) -> String {
|
|
for (_, child) in &self.children {
|
|
let v = child.selection_get_text_for_range(start, end);
|
|
if !v.is_empty() {
|
|
return v;
|
|
}
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
fn selection_get_full_text(&self) -> String {
|
|
for (_, child) in &self.children {
|
|
let v = child.selection_get_full_text();
|
|
if !v.is_empty() {
|
|
return v;
|
|
}
|
|
}
|
|
String::new()
|
|
}
|
|
|
|
fn set_visible(&mut self, cx: &mut Cx, visible: bool) {
|
|
if self.visible != visible {
|
|
self.visible = visible;
|
|
// A view that has never drawn has an empty area, so its own
|
|
// redraw is a no-op and the visibility change only lands on
|
|
// the next unrelated full repaint (the classic "appears after
|
|
// hot reload" bug). Escalate to a full redraw in that case.
|
|
if visible && matches!(self.area, Area::Empty) {
|
|
cx.redraw_all();
|
|
} else {
|
|
self.redraw(cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn visible(&self) -> bool {
|
|
self.visible
|
|
}
|
|
}
|
|
|
|
impl Widget for View {
|
|
fn script_call(
|
|
&mut self,
|
|
vm: &mut ScriptVm,
|
|
method: LiveId,
|
|
args: ScriptValue,
|
|
) -> ScriptAsyncResult {
|
|
if method == live_id!(render) {
|
|
let me = self.make_render_me(vm);
|
|
return vm.with_cx_mut(|cx| {
|
|
cx.widget_to_script_async_call_fwd(
|
|
self.uid,
|
|
&mut self.script_async,
|
|
me,
|
|
self.source.clone(),
|
|
self.on_render.clone(),
|
|
args,
|
|
id!(render),
|
|
)
|
|
});
|
|
}
|
|
if method == live_id!(set_visible) {
|
|
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);
|
|
let visible = value
|
|
.as_bool()
|
|
.or_else(|| value.as_number().map(|n| n != 0.0));
|
|
match visible {
|
|
Some(visible) => {
|
|
vm.with_cx_mut(|cx| {
|
|
if self.visible != visible {
|
|
self.visible = visible;
|
|
self.redraw(cx);
|
|
}
|
|
});
|
|
}
|
|
// Never guess on a bad argument: a silent default (especially
|
|
// `true`) turns script bugs into invisible misbehavior. Keep
|
|
// the current visibility and surface an error instead.
|
|
None => {
|
|
return ScriptAsyncResult::Return(script_err_wrong_value!(
|
|
vm.trap(),
|
|
"set_visible expects a bool (or number), got {:?}",
|
|
value.value_type()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
return ScriptAsyncResult::Return(NIL);
|
|
}
|
|
ScriptAsyncResult::MethodNotFound
|
|
}
|
|
|
|
fn script_result(&mut self, vm: &mut ScriptVm, id: ScriptAsyncId, result: ScriptValue) {
|
|
let Some(call) = self.script_async.take(id) else {
|
|
return;
|
|
};
|
|
|
|
if call.method() == id!(render) {
|
|
if result.is_err() {
|
|
// An error mid-closure abandons every child emitted before it
|
|
// and used to do so with ZERO diagnostics — the "on_render
|
|
// silently renders nothing" family cost days to bisect. Say
|
|
// what happened.
|
|
let msg = vm.bx.heap.temp_string_with(|heap, out| {
|
|
heap.cast_to_string(result, out);
|
|
out.to_string()
|
|
});
|
|
error!("on_render closure failed; discarding its output: {msg}");
|
|
return;
|
|
}
|
|
if let Some(me_obj) = call.me().as_object() {
|
|
// The closure's FINAL statement becomes its return value (the
|
|
// parser converts the last commit into a RETURN), so a widget
|
|
// emitted last would otherwise vanish. Commit it as the last
|
|
// child; non-widget values are skipped downstream anyway.
|
|
if let Some(ret_obj) = result.as_object() {
|
|
if ret_obj != me_obj {
|
|
// Unchecked: `me` is this render's own throwaway
|
|
// object (make_render_me), never frozen.
|
|
vm.bx.heap.vec_push_unchecked(me_obj, NIL, result);
|
|
}
|
|
}
|
|
// `#[source]` is reassigned by any non-eval script apply, so this Reload
|
|
// would leave `source` pointing at `me`. Keep it on the declaration object:
|
|
// the next `make_render_me` protos off it (see there), and `me` is a
|
|
// throwaway whose children already hold their own refs.
|
|
let declaration = self.source.clone();
|
|
self.script_apply(vm, &Apply::Reload, &mut Scope::empty(), me_obj.into());
|
|
self.source = declaration;
|
|
self.redraw(vm.cx_mut());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_interactive(&self) -> bool {
|
|
self.cursor.is_some() || self.animator.is_defined
|
|
}
|
|
|
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
|
if !self.visible && event.requires_visibility() {
|
|
return;
|
|
}
|
|
|
|
let uid = self.widget_uid();
|
|
if self.animator_handle_event(cx, event).must_redraw() {
|
|
self.redraw(cx);
|
|
}
|
|
|
|
if self.block_signal_event {
|
|
if let Event::Signal = event {
|
|
return;
|
|
}
|
|
}
|
|
if let Event::ClearHover = event {
|
|
if self.animator.is_defined {
|
|
self.animator_play(cx, ids!(hover.off));
|
|
self.animator_play(cx, ids!(down.off));
|
|
}
|
|
}
|
|
// A press that catches an in-progress momentum fling stops the scroll and is consumed:
|
|
// it must not also activate a child widget under the finger, as on iOS, Android, and
|
|
// macOS. So when the scroll bars report a caught fling, skip dispatching this press to
|
|
// children. This runs before the child loop below, since children would otherwise
|
|
// capture the press first.
|
|
let mut fling_caught = false;
|
|
if let Some(scroll_bars) = &mut self.scroll_bars_obj {
|
|
let mut actions = Vec::new();
|
|
scroll_bars.handle_main_event(cx, event, scope, &mut actions);
|
|
if actions.len() > 0 {
|
|
cx.redraw_area_and_children(self.area);
|
|
};
|
|
fling_caught = scroll_bars.catch_fling_on_press(cx, event);
|
|
}
|
|
|
|
if !fling_caught {
|
|
match &self.event_order {
|
|
EventOrder::Up => {
|
|
for (_id, child) in self.children.iter_mut().rev() {
|
|
child.handle_event(cx, event, scope);
|
|
}
|
|
}
|
|
EventOrder::Down => {
|
|
for (_id, child) in self.children.iter_mut() {
|
|
child.handle_event(cx, event, scope);
|
|
}
|
|
}
|
|
EventOrder::List(list) => {
|
|
for id in list {
|
|
if let Some((_, child)) =
|
|
self.children.iter_mut().find(|(id2, _)| id2 == id)
|
|
{
|
|
child.handle_event(cx, event, scope);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
event.hit_tweak_ray(self.area(), self.widget_uid());
|
|
|
|
// Also skip this View's own press-driven hit handling when a fling was caught, so the
|
|
// catching press is fully consumed (it neither activates a child nor fires this
|
|
// View's own FingerDown/animator). `fling_caught` is only ever true on a press event
|
|
// over a flinging scroll view, so key/hover handling is unaffected.
|
|
if !fling_caught && (self.visible && self.cursor.is_some() || self.animator.is_defined) {
|
|
match event.hits_with_capture_overload(cx, self.area(), self.capture_overload) {
|
|
Hit::FingerDown(e) => {
|
|
if self.grab_key_focus {
|
|
cx.set_key_focus(self.area());
|
|
}
|
|
cx.widget_action(uid, ViewAction::FingerDown(e));
|
|
if self.animator.is_defined {
|
|
self.animator_play(cx, ids!(down.on));
|
|
}
|
|
}
|
|
Hit::FingerMove(e) => cx.widget_action(uid, ViewAction::FingerMove(e)),
|
|
Hit::FingerLongPress(e) => cx.widget_action(uid, ViewAction::FingerLongPress(e)),
|
|
Hit::FingerUp(e) => {
|
|
cx.widget_action(uid, ViewAction::FingerUp(e));
|
|
if self.animator.is_defined {
|
|
self.animator_play(cx, ids!(down.off));
|
|
}
|
|
}
|
|
Hit::FingerHoverIn(e) => {
|
|
cx.widget_action(uid, ViewAction::FingerHoverIn(e));
|
|
if let Some(cursor) = &self.cursor {
|
|
cx.set_cursor(*cursor);
|
|
}
|
|
if self.animator.is_defined {
|
|
self.animator_play(cx, ids!(hover.on));
|
|
}
|
|
}
|
|
Hit::FingerHoverOut(e) => {
|
|
cx.widget_action(uid, ViewAction::FingerHoverOut(e));
|
|
if self.animator.is_defined {
|
|
self.animator_play(cx, ids!(hover.off));
|
|
}
|
|
}
|
|
Hit::KeyDown(e) => cx.widget_action(uid, ViewAction::KeyDown(e)),
|
|
Hit::KeyUp(e) => cx.widget_action(uid, ViewAction::KeyUp(e)),
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
if let Some(scroll_bars) = &mut self.scroll_bars_obj {
|
|
scroll_bars.handle_scroll_event(cx, event, scope, &mut Vec::new());
|
|
}
|
|
}
|
|
|
|
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
|
// the beginning state
|
|
if self.draw_state.begin(cx, DrawState::Drawing(0, false)) {
|
|
if !self.visible {
|
|
self.draw_state.end();
|
|
return DrawStep::done();
|
|
}
|
|
|
|
self.defer_walks.clear();
|
|
|
|
match self.optimize {
|
|
ViewOptimize::Texture => {
|
|
let walk = self.walk_from_previous_size(walk);
|
|
// A repopulate or optimize-mode flip forces one real re-render: the size-based
|
|
// cache check can't see content changes on a recycled/toggled view.
|
|
let force = std::mem::take(&mut self.force_texture_redraw);
|
|
// Re-render whenever the on-screen rect changes (position or size) or a
|
|
// repopulate/mode-flip forced it; only a truly unchanged view cache-hits.
|
|
if !force && !cx.will_redraw(self.draw_list.as_mut().unwrap(), walk) {
|
|
if let Some(texture_cache) = &self.texture_cache {
|
|
self.draw_bg
|
|
.draw_vars
|
|
.set_texture(0, &texture_cache.color_texture);
|
|
let mut rect = cx.walk_turtle_with_area(&mut self.area, walk);
|
|
// NOTE(eddyb) see comment lower below for why this is
|
|
// disabled (it used to match `set_pass_scaled_area`).
|
|
if false {
|
|
rect.size *= 2.0 / self.dpi_factor.unwrap_or(1.0);
|
|
}
|
|
self.draw_bg.draw_abs(cx, rect);
|
|
self.area = self.draw_bg.area();
|
|
/*if false {
|
|
// FIXME(eddyb) this was the previous logic,
|
|
// but the only tested apps that use `CachedView`
|
|
// are sized correctly (regardless of `dpi_factor`)
|
|
// *without* extra scaling here.
|
|
cx.set_pass_scaled_area(
|
|
&texture_cache.pass,
|
|
self.area,
|
|
2.0 / self.dpi_factor.unwrap_or(1.0),
|
|
);
|
|
} else {*/
|
|
cx.set_pass_area(&texture_cache.pass, self.area);
|
|
//}
|
|
}
|
|
return DrawStep::done();
|
|
}
|
|
// lets start a pass
|
|
if self.texture_cache.is_none() {
|
|
self.texture_cache = Some(ViewTextureCache {
|
|
pass: DrawPass::new(cx),
|
|
_depth_texture: Texture::new(cx),
|
|
color_texture: Texture::new(cx),
|
|
});
|
|
let texture_cache = self.texture_cache.as_mut().unwrap();
|
|
//cache.pass.set_depth_texture(cx, &cache.depth_texture, PassClearDepth::ClearWith(1.0));
|
|
texture_cache.color_texture = Texture::new_with_format(
|
|
cx,
|
|
TextureFormat::RenderBGRAu8 {
|
|
size: TextureSize::Auto,
|
|
initial: true,
|
|
},
|
|
);
|
|
texture_cache.pass.set_color_texture(
|
|
cx,
|
|
&texture_cache.color_texture,
|
|
DrawPassClearColor::ClearWith(vec4(0.0, 0.0, 0.0, 0.0)),
|
|
);
|
|
}
|
|
let texture_cache = self.texture_cache.as_mut().unwrap();
|
|
cx.make_child_pass(&texture_cache.pass);
|
|
cx.begin_pass(&texture_cache.pass, self.dpi_factor);
|
|
self.draw_list.as_mut().unwrap().begin_always(cx)
|
|
}
|
|
ViewOptimize::DrawList => {
|
|
let walk = self.walk_from_previous_size(walk);
|
|
if self
|
|
.draw_list
|
|
.as_mut()
|
|
.unwrap()
|
|
.begin(cx, walk)
|
|
.is_not_redrawing()
|
|
{
|
|
cx.walk_turtle_with_area(&mut self.area, walk);
|
|
return DrawStep::done();
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
|
|
// ok so.. we have to keep calling draw till we return LiveId(0)
|
|
let scroll = if let Some(scroll_bars) = &mut self.scroll_bars_obj {
|
|
scroll_bars.begin_nav_area(cx);
|
|
scroll_bars.get_scroll_pos()
|
|
} else {
|
|
self.layout.scroll
|
|
};
|
|
|
|
// Only Texture mode allocates an offscreen render target sized to this turtle, so cap
|
|
// its height there. None/DrawList render into the parent turtle and must stay uncapped.
|
|
let walk = if self.optimize.is_texture() {
|
|
self.walk_with_texture_max_height(walk)
|
|
} else {
|
|
walk
|
|
};
|
|
|
|
if self.show_bg {
|
|
/*if let Some(image_texture) = &self.image_texture {
|
|
self.draw_bg.draw_vars.set_texture(0, image_texture);
|
|
}*/
|
|
self.draw_bg
|
|
.begin(cx, walk, self.layout.with_scroll(scroll)); //.with_scale(2.0 / self.dpi_factor.unwrap_or(2.0)));
|
|
} else {
|
|
cx.begin_turtle(walk, self.layout.with_scroll(scroll)); //.with_scale(2.0 / self.dpi_factor.unwrap_or(2.0)));
|
|
}
|
|
}
|
|
|
|
while let Some(DrawState::Drawing(step, resume)) = self.draw_state.get() {
|
|
if step < self.children.len() {
|
|
//let id = self.draw_order[step];
|
|
if let Some((id, child)) = self.children.get_mut(step) {
|
|
if child.visible() {
|
|
let walk = child.walk(cx);
|
|
if resume {
|
|
child.draw_walk(cx, scope, walk)?;
|
|
} else if let Some(fw) = cx.defer_walk_turtle(walk) {
|
|
self.defer_walks.push((*id, fw));
|
|
} else {
|
|
self.draw_state.set(DrawState::Drawing(step, true));
|
|
child.draw_walk(cx, scope, walk)?;
|
|
}
|
|
}
|
|
}
|
|
self.draw_state.set(DrawState::Drawing(step + 1, false));
|
|
} else {
|
|
self.draw_state.set(DrawState::DeferWalk(0));
|
|
}
|
|
}
|
|
|
|
while let Some(DrawState::DeferWalk(step)) = self.draw_state.get() {
|
|
if step < self.defer_walks.len() {
|
|
let (id, dw) = &mut self.defer_walks[step];
|
|
if let Some((_id, child)) = self.children.iter_mut().find(|(id2, _)| id2 == id) {
|
|
let walk = dw.resolve(cx);
|
|
child.draw_walk(cx, scope, walk)?;
|
|
}
|
|
self.draw_state.set(DrawState::DeferWalk(step + 1));
|
|
} else {
|
|
if let Some(scroll_bars) = &mut self.scroll_bars_obj {
|
|
scroll_bars.draw_scroll_bars(cx);
|
|
};
|
|
|
|
if self.show_bg {
|
|
if self.optimize.is_texture() {
|
|
panic!("dont use show_bg and texture caching at the same time");
|
|
}
|
|
self.draw_bg.end(cx);
|
|
self.area = self.draw_bg.area();
|
|
} else {
|
|
cx.end_turtle_with_area(&mut self.area);
|
|
};
|
|
|
|
if let Some(scroll_bars) = &mut self.scroll_bars_obj {
|
|
scroll_bars.set_area(self.area);
|
|
scroll_bars.end_nav_area(cx);
|
|
};
|
|
|
|
// Track measured size in every mode. The None path must update it too, or a later
|
|
// Texture draw sizes its offscreen turtle to a stale height and clips the content.
|
|
self.view_size = Some(self.area.rect(cx).size);
|
|
|
|
if self.optimize.needs_draw_list() {
|
|
let rect = self.area.rect(cx);
|
|
self.draw_list.as_mut().unwrap().end(cx);
|
|
|
|
if self.optimize.is_texture() {
|
|
let texture_cache = self.texture_cache.as_mut().unwrap();
|
|
cx.end_pass(&texture_cache.pass);
|
|
/*if cache.pass.id_equals(4){
|
|
self.draw_bg.draw_vars.set_uniform(cx, id!(marked),&[1.0]);
|
|
}
|
|
else{
|
|
self.draw_bg.draw_vars.set_uniform(cx, id!(marked),&[0.0]);
|
|
}*/
|
|
self.draw_bg
|
|
.draw_vars
|
|
.set_texture(0, &texture_cache.color_texture);
|
|
self.draw_bg.draw_abs(cx, rect);
|
|
let area = self.draw_bg.area();
|
|
let texture_cache = self.texture_cache.as_mut().unwrap();
|
|
/* if false {
|
|
// FIXME(eddyb) this was the previous logic,
|
|
// but the only tested apps that use `CachedView`
|
|
// are sized correctly (regardless of `dpi_factor`)
|
|
// *without* extra scaling here.
|
|
cx.set_pass_scaled_area(
|
|
&texture_cache.pass,
|
|
area,
|
|
2.0 / self.dpi_factor.unwrap_or(1.0),
|
|
);
|
|
} else {*/
|
|
cx.set_pass_area(&texture_cache.pass, area);
|
|
//}
|
|
}
|
|
}
|
|
self.draw_state.end();
|
|
}
|
|
}
|
|
DrawStep::done()
|
|
}
|
|
}
|
|
|
|
trait EventTweakRayExt {
|
|
fn hit_tweak_ray(&self, area: Area, widget_uid: WidgetUid);
|
|
}
|
|
|
|
impl EventTweakRayExt for Event {
|
|
fn hit_tweak_ray(&self, area: Area, widget_uid: WidgetUid) {
|
|
if let Event::TweakRay(e) = self {
|
|
if !area.is_empty() {
|
|
e.hit_widget_uids.borrow_mut().push(widget_uid.0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum DrawState {
|
|
Drawing(usize, bool),
|
|
DeferWalk(usize),
|
|
}
|
|
|
|
impl View {
|
|
pub fn swap_child(&mut self, pos_a: usize, pos_b: usize) {
|
|
self.children.swap(pos_a, pos_b);
|
|
}
|
|
|
|
pub fn child_index(&mut self, comp: &WidgetRef) -> Option<usize> {
|
|
if let Some(pos) = self.children.iter().position(|(_, w)| w == comp) {
|
|
Some(pos)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn child_at_index(&mut self, index: usize) -> Option<&WidgetRef> {
|
|
if let Some(f) = self.children.get(index) {
|
|
Some(&f.1)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn set_scroll_pos(&mut self, cx: &mut Cx, v: Vec2d) {
|
|
if let Some(scroll_bars) = &mut self.scroll_bars_obj {
|
|
scroll_bars.set_scroll_pos(cx, v);
|
|
} else {
|
|
self.layout.scroll = v;
|
|
}
|
|
}
|
|
|
|
pub fn area(&self) -> Area {
|
|
self.area
|
|
}
|
|
|
|
/// Switch this view's draw optimization at runtime (e.g. toggle texture caching per frame).
|
|
/// Allocates the backing draw_list on demand when promoting to a draw-list-backed mode.
|
|
pub fn set_optimize(&mut self, cx: &mut Cx, optimize: ViewOptimize) {
|
|
// A mode flip (e.g. None<->Texture) is a guaranteed content/layout change, and the None
|
|
// path never updates view_size, so force one fresh texture re-render on the change.
|
|
if core::mem::discriminant(&self.optimize) != core::mem::discriminant(&optimize) {
|
|
self.redraw_texture_cache();
|
|
}
|
|
self.optimize = optimize;
|
|
if self.optimize.needs_draw_list() && self.draw_list.is_none() {
|
|
self.draw_list = Some(DrawList2d::new(cx));
|
|
}
|
|
}
|
|
|
|
/// Forces the next Texture-mode draw to re-render its offscreen texture instead of compositing
|
|
/// the cached one, and drops the stale measured size so the re-render sizes to the new content.
|
|
/// Call after repopulating a texture-cached view's children.
|
|
pub fn redraw_texture_cache(&mut self) {
|
|
self.force_texture_redraw = true;
|
|
self.view_size = None;
|
|
}
|
|
|
|
/// Caps the offscreen texture's height when this view is in Texture mode. `None` (the default)
|
|
/// leaves it uncapped. Only useful for a Fit-height cached view whose content can be taller than
|
|
/// the GPU's max texture size; content past the cap is clipped.
|
|
pub fn set_texture_max_height(&mut self, max: Option<f64>) {
|
|
if self.texture_max_height != max {
|
|
self.texture_max_height = max;
|
|
self.redraw_texture_cache();
|
|
}
|
|
}
|
|
|
|
fn walk_with_texture_max_height(&self, walk: Walk) -> Walk {
|
|
match self.texture_max_height {
|
|
Some(max) if !walk.height.is_fill() => Walk { height: Size::Fixed(max), ..walk },
|
|
_ => walk,
|
|
}
|
|
}
|
|
|
|
pub fn walk_from_previous_size(&self, walk: Walk) -> Walk {
|
|
// Fill and Fixed sizes are already known before drawing, so keep them live —
|
|
// a Fixed size can be fresh truth for this frame (e.g. a deferred fill the
|
|
// parent just resolved), and pinning it to the previous frame's measurement
|
|
// would make the cached-draw dirty check miss a pure size change. Only
|
|
// content-driven sizes fall back to the previous measurement, since they
|
|
// cannot be known before the children draw.
|
|
let view_size = self.view_size.unwrap_or(Vec2d::default());
|
|
Walk {
|
|
abs_pos: walk.abs_pos,
|
|
width: if walk.width.is_fill() || walk.width.is_fixed() {
|
|
walk.width
|
|
} else {
|
|
Size::Fixed(view_size.x)
|
|
},
|
|
height: if walk.height.is_fill() || walk.height.is_fixed() {
|
|
walk.height
|
|
} else {
|
|
Size::Fixed(view_size.y)
|
|
},
|
|
margin: walk.margin,
|
|
metrics: Metrics::default(),
|
|
}
|
|
}
|
|
|
|
pub fn child_count(&self) -> usize {
|
|
self.children.len()
|
|
}
|
|
|
|
pub fn debug_print_children(&self) {
|
|
// Debug output removed
|
|
}
|
|
|
|
pub fn debug_props(&self) -> String {
|
|
format!(
|
|
"walk=({:?},{:?}) flow={:?} show_bg={} visible={} padding=({},{},{},{}) spacing={:?}",
|
|
self.walk.width,
|
|
self.walk.height,
|
|
self.layout.flow,
|
|
self.show_bg,
|
|
self.visible,
|
|
self.layout.padding.left,
|
|
self.layout.padding.top,
|
|
self.layout.padding.right,
|
|
self.layout.padding.bottom,
|
|
self.layout.spacing
|
|
)
|
|
}
|
|
|
|
pub fn debug_dump_tree(&self, depth: usize) -> String {
|
|
let mut out = String::new();
|
|
let indent = " ".repeat(depth);
|
|
out.push_str(&format!("{}View {{ {} }}\n", indent, self.debug_props()));
|
|
for (id, child) in &self.children {
|
|
out.push_str(&format!("{} [id={:?}] ", indent, id));
|
|
if let Some(view) = child.borrow_mut::<View>() {
|
|
out.push_str(&format!("\n{}", view.debug_dump_tree(depth + 2)));
|
|
} else {
|
|
let text = child.text();
|
|
if text.is_empty() {
|
|
out.push_str("<widget>\n");
|
|
} else {
|
|
out.push_str(&format!("<widget text={:?}>\n", text));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn set_key_focus(&self, cx: &mut Cx) {
|
|
cx.set_key_focus(self.draw_bg.area());
|
|
}
|
|
}
|