Compare commits

..
Author SHA1 Message Date
iris 1e4a7f8cf5 naw 2025-12-29 20:11:35 -05:00
iris 93291badc1 (BROKEN) start on removing desired size 2025-12-23 23:48:35 -05:00
169 changed files with 4807 additions and 28983 deletions

No files matched your search

Generated
+759 -1987
View File
File diff suppressed because it is too large. Load diff
+15 -82
View File
@@ -1,105 +1,38 @@
[package] [package]
name = "iris" name = "iris"
default-run = "test"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
iris-core = { workspace = true } iris-core = { workspace = true }
iris-macro = { workspace = true } iris-macro = { workspace = true }
parley = { workspace = true } cosmic-text = { workspace = true }
swash = { workspace = true } unicode-segmentation = { workspace = true }
winit = { workspace = true }
arboard = { workspace = true, features = ["wayland-data-control"] }
pollster = { workspace = true } pollster = { workspace = true }
wgpu = { workspace = true } wgpu = { workspace = true }
image = { workspace = true } image = { workspace = true }
accesskit = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
# The embedding app installs the logger.
log = "0.4.34"
# winit's Android backend conflicts with android-view, which owns that platform here.
[target.'cfg(not(target_os = "android"))'.dependencies]
winit = { workspace = true }
arboard = { workspace = true, features = ["wayland-data-control"] }
accesskit_winit = "0.34.0"
# Advancing this measured revision requires rechecking rendering, IME, and detach.
[target.'cfg(target_os = "android")'.dependencies]
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
# 0.8.0 still aborts on detach; `view.rs::raise_if_enabled` mitigates it.
accesskit_android = "0.8.0"
send_wrapper = "0.6.0"
[features]
# Forces GL on Vulkan-capable hosts for comparisons. The emulator already falls back
# to hardware GLES; enabling this there would make its build unlike the phone's.
force-gles = []
[dev-dependencies]
bytemuck = { workspace = true }
[[example]]
name = "bench_images"
path = "examples/bench_images/desktop.rs"
[[example]]
name = "message_list"
path = "examples/message_list/desktop.rs"
[[example]]
name = "minimal"
path = "examples/minimal/desktop.rs"
[[example]]
name = "tabs"
path = "examples/tabs/desktop.rs"
[[example]]
name = "task"
path = "examples/task/desktop.rs"
[[example]]
name = "text"
path = "examples/text/desktop.rs"
[[example]]
name = "view"
path = "examples/view/desktop.rs"
[[bench]]
name = "message_list"
harness = false
[workspace] [workspace]
members = [ members = ["core", "macro"]
"cargo-iris",
"core",
"macro",
"rig-input",
]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
# Full DWARF once produced 54 GB of writes and an 88 GB target because every test
# statically links the renderer stack. Use `RUSTFLAGS="-C debuginfo=2"` when needed.
[profile.dev]
debug = "line-tables-only"
[profile.test]
debug = "line-tables-only"
[workspace.dependencies] [workspace.dependencies]
pollster = "1.0.1" pollster = "0.4.0"
winit = "0.30.13" winit = "0.30.12"
wgpu = "30.0.1" wgpu = "27.0.1"
bytemuck = "1.25.2" bytemuck = "1.23.1"
image = "0.25.10" image = "0.25.6"
parley = "0.11.1" cosmic-text = "0.15.0"
swash = "0.2.10" unicode-segmentation = "1.12.0"
fxhash = "0.2.1" fxhash = "0.2.1"
arboard = "3.6.1" arboard = "3.6.1"
accesskit = "0.25.0"
iris-core = { path = "core" } iris-core = { path = "core" }
iris-macro = { path = "macro" } iris-macro = { path = "macro" }
tokio = "1.53.1"
+32 -67
View File
@@ -1,75 +1,40 @@
# iris: known problems and things still to build images
settings (sampler)
Only open Iris framework work lives here. Delete an item when it lands. text
figure out ways to speed up / what costs the most
resizing (per frame) is really slow (assuming painter isn't griefing)
j is weird / fix x offset
## Build (for the port) masks r just made to bare minimum work
Framework capabilities needed by `RUST.md`'s port plan: scaling
could be just a simple scaling factor that multiplies abs
and need to ensure text uses raw abs and not scaled abs
naming? (pt, px)
want to keep (drawn) regions using px? or should I add another field to UiScalar/Vec
field could be best solution so redrawing stuff isn't needed & you can specify both as user
- [ ] **Overflow ellipsis with an explicit retained end.** `TextAttrs` can WidgetRef<W> or smth instead of Id
only wrap or clip, so a tool summary is cut with no mark. Parley has no enum that's either an Id or an actual concrete instance of W
ellipsis primitive; use its line breaker to find the cut, but keep source painter takes them in instead of (or in addition to) id
and displayed strings distinct with one byte mapping shared by spans, then type wrapper widgets to contain them
links, selection and editing. Replace `wrap: bool` with an enum that can allows for compile time optimization if a widget wrapper's inner is known at compile time
say wrap, clip, head ellipsis and tail ellipsis—the caller must choose and the id of inner is not needed anywhere
because a command is identified by its head and a path by its tail. maybe introduce InnerWidget trait to allow for editors to expose & modify inner type
- [ ] **Expose the distance from a `LazySpan` viewport to its unloaded maybe could also store a parent widget and keep using InnerWidget trait? unsure if possible
edge.** (**P1**.) `viewport_len` and the visible extents are already
measured internally, but a paging caller cannot ask whether it is within
the product's six-viewport `HISTORY_SCREENS` cushion. The API should
answer in pixels or viewport multiples, never rows: a row ranges from one
line to a screen, so a fixed row count is not a distance.
- [ ] **Let an image fit a bounded box while preserving its aspect ratio.**
(**P1**.) `Image` currently always reports and draws the decoded texture's
natural pixel size. Decoding and fetching a server-produced attachment
belong in `app`; iris only owes the generic fit/scale widget used to
draw its thumbnail.
- [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is
already monospace and coloured, but the inline-code chip also needs
the glyph run's boxes so a surface can be drawn behind exactly that byte
range. The shared `TextSelection` engine already computes the same geometry
for selection highlights; expose one shared primitive rather than giving
the app a second text-layout path.
- [ ] **A horizontal gauge/bar widget.** (**P1**.) For
`SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction,
nothing fancier.
- [ ] **A toggle switch.** (**P3**.) For the delete dialog's
`deleteForeign` control; iris has no switch/checkbox widget yet as far
as this pass found.
## Later really weird limitation:
I don't think you can currently remove an element from a parent and put it in a child of the same parent
because it removes the unused children after the entire parent redraw
but the child gets drawn during that, so it will think the child is still active !!!
or something like that idk, maybe I need a special enum for parent that includes a undecided state where it may or may not get redrawn by the parent
or just do ref counting and ensure all drawn things == 1 afterwards (seems like best way)
ok so I'm removing the limit for now
- [ ] **Intern independently constructed solid paint definitions.** Inline don't forget I'm streaming
`rect(Srgba8::...)` values currently receive a new `PaintId` each time.
Cache them by canonical linear RGBA bits, but keep `Paints::add` explicitly
unique so two semantic theme roles that start with the same value can later
change independently. Cache entries must be weak and disappear when the
last real handle releases the slot; gradients and texture paints need their
own identity rules rather than inheriting solid-value interning blindly.
- [ ] **Property/content animations.** Cosmetic, so after correctness and tags
parity. Keep them modular, like input; scrolling already animates through vecs for each widget type?
`Widget::tick` and `UiData::animate`. A widget that does not opt in must
pay nothing and import nothing for them.
- [ ] **Remove `WidgetView` unless a real composite adopts it.** Every POTENTIAL BUG: closures that store IDs will not decrement the id!!! need to not increment id if moved into closure somehow??? wait no, need to decrement ID every time an event fn is added...... only if the id is used in it..??
composite in `app/src/ui` uses ordinary child handles plus a root;
`WidgetView` and its derive are used only by `iris/examples/view/lib.rs`.
It currently demonstrates itself rather than shortening production code.
- [ ] **A `Stack` that chooses its mask the way it chooses its size
should replace `masked_by`.** For a square-cornered surface,
`.background(rect(BAR_FILL)).masked()` was measured
against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own
size and density and the two are identical to the pixel. What the pair
cannot express is a clip that is not a box: `Painter::set_mask` writes
a `RectPrimitive::color` using `PaintId::NONE` at the widget's own region,
with no radius, so `.background(rect(fill).radius(r)).masked()` draws a
rounded panel and then cuts its content square. Both other call sites
(`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why
the method stands for now.
Let `Stack` name the mask child the way `StackSize::Child(n)` names the
sizing child. Then `.background(x)` remains the one way to add a surface
and clipping to it is a stack property; the named mask child must have
drawn before any child that uses it. Once that exists, delete
`masked_by` and `Masked::shape` rather than retaining two APIs.
-383
View File
@@ -1,383 +0,0 @@
use iris::prelude::*;
use std::time::Instant;
struct BenchRsc {
ui: Ui,
}
impl UiRsc for BenchRsc {
fn ui(&self) -> &Ui {
&self.ui
}
fn ui_mut(&mut self) -> &mut Ui {
&mut self.ui
}
}
const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \
out wrapped text by shaping once per width and caching the result, so a \
row that is offered the same width twice does not reshape. This sentence \
exists only to give a row enough text to wrap across several lines at a \
typical phone column width.";
fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
let text = wtext(format!("Message {i}: {BODY}"))
.overflow(TextOverflow::Wrap)
.add_strong(rsc)
.any();
if image_every > 0 && i.is_multiple_of(image_every) {
let img = image::DynamicImage::new_rgba8(64, 64);
let image_widget = image::<BenchRsc>(img)(rsc);
let image_widget = rsc.ui.widgets.add_strong(image_widget).any();
let mut row = Span::empty(Dir::DOWN);
row.push(text);
row.push(image_widget);
rsc.ui.widgets.add_strong(row).any()
} else {
text
}
}
fn build_message_list(
rsc: &mut BenchRsc,
n: usize,
image_every: usize,
) -> (WeakWidget<LazySpan>, StrongWidget) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for i in 0..n {
let row = build_row(rsc, i, image_every);
list.push_back(LazyItem::new(i as u64, row));
}
let list = rsc.ui.widgets.add_strong(list);
(list.weak(), list.any())
}
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
println!(
"{label}: {:.2}ms draws={draws} rewrites={rewrites} moves={moves}",
elapsed.as_secs_f64() * 1000.0
);
}
fn bench_first_frame(n: usize) {
let mut rsc = BenchRsc { ui: Ui::default() };
let (_list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
let start = Instant::now();
render.update(&root, &mut rsc);
let elapsed = start.elapsed();
let RenderCounters {
draws,
region_rewrites: rewrites,
moves,
..
} = render.take_counters();
report(
&format!("(a) first frame, N={n}"),
elapsed,
draws,
rewrites,
moves,
);
}
fn bench_scroll(n: usize, ticks: usize) {
let mut rsc = BenchRsc { ui: Ui::default() };
let (scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for _ in 0..ticks {
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0);
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let RenderCounters {
draws,
region_rewrites: rewrites,
moves,
..
} = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!("(b) scroll, N={n}, {ticks} ticks (totals; expect draws/moves independent of N)"),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-tick average: {:.4}ms",
total.as_secs_f64() * 1000.0 / ticks as f64
);
}
fn bench_input_grows(n: usize, lines: usize) {
let mut rsc = BenchRsc { ui: Ui::default() };
let (scroll, list_root) = build_message_list(&mut rsc, n, 20);
let list_area = rsc.ui.widgets.add_strong(Sized {
inner: list_root,
x: None,
y: Some(rest(1.0)),
});
let line_height = 24.0;
let input_rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let input_area = rsc.ui.widgets.add_strong(Sized {
inner: input_rect.any(),
x: None,
y: Some(abs(line_height).into()),
});
let input_area_weak = input_area.weak();
let mut root_span = Span::empty(Dir::DOWN);
root_span.push(list_area.any());
root_span.push(input_area.any());
let root = rsc.ui.widgets.add_strong(root_span).any();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for line in 1..=lines {
rsc.ui.widgets.get_mut(&input_area_weak).unwrap().y =
Some(abs(line_height * (line + 1) as f32).into());
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let RenderCounters {
draws,
region_rewrites: rewrites,
moves,
..
} = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(c) input grows by {lines} lines above N={n} rows (totals; \
draws/rewrites must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-line average: {:.4}ms",
total.as_secs_f64() * 1000.0 / lines as f64
);
}
fn bench_insert_above_anchor(n: usize, inserts: usize) {
let mut rsc = BenchRsc { ui: Ui::default() };
let (list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start();
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for i in 0..inserts {
let row = build_row(&mut rsc, usize::MAX - i, 20);
rsc.ui
.widgets
.get_mut(&list)
.unwrap()
.push_front(LazyItem::new(i as u64, row));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let RenderCounters {
draws,
region_rewrites: rewrites,
moves,
..
} = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \
must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-push average: {:.4}ms",
total.as_secs_f64() * 1000.0 / inserts as f64
);
}
fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut rsc = BenchRsc { ui: Ui::default() };
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
let growable_index = n.saturating_sub(3);
let mut growable = None;
for i in 0..n {
if i == growable_index {
let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(abs(40.0).into()),
});
growable = Some(sized.weak());
list.push_back(LazyItem::new(i as u64, sized.any()));
} else {
let row = build_row(&mut rsc, i, 20);
list.push_back(LazyItem::new(i as u64, row));
}
}
let list = rsc.ui.widgets.add_strong(list);
let list_weak = list.weak();
let root = list.any();
let growable = growable.unwrap();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
let mut height = 40.0f32;
let key = growable_index as u64;
for _ in 0..growths {
height += 10.0;
if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) {
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.note_tap(top + 1.0);
}
rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height).into());
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let RenderCounters {
draws,
region_rewrites: rewrites,
moves,
..
} = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(e) expand-hold, N={n}, {growths} growths (totals; \
must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-growth average: {:.4}ms",
total.as_secs_f64() * 1000.0 / growths as f64
);
}
fn bench_redraw_big_text(chars: usize, redraws: usize) {
let mut rsc = BenchRsc { ui: Ui::default() };
let content: String = (0..chars)
.map(|i| char::from(b'a' + (i % 26) as u8))
.collect();
let text = wtext(content)
.overflow(TextOverflow::Wrap)
.add_strong(&mut rsc);
let handle = text.weak();
let root = text.any();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
for _ in 0..redraws {
rsc.ui.widgets.get_mut(&handle).unwrap();
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
}
let RenderCounters {
draws,
region_rewrites: rewrites,
moves,
..
} = render.take_counters();
report(
&format!("(g) redraw one {chars}-glyph text, {redraws}x (totals)"),
total,
draws,
rewrites,
moves,
);
println!(
" per redraw: {:.3}ms, per glyph: {:.4}us",
total.as_secs_f64() * 1000.0 / redraws as f64,
total.as_secs_f64() * 1_000_000.0 / (redraws * chars) as f64,
);
}
fn main() {
println!("iris message-list benchmark -- release build, this machine's CPU");
for &n in &[100usize, 1_000, 10_000] {
bench_first_frame(n);
}
for &n in &[100usize, 1_000, 10_000] {
bench_scroll(n, 200);
}
for &n in &[100usize, 1_000, 10_000] {
bench_input_grows(n, 40);
}
for &n in &[100usize, 1_000, 10_000] {
bench_insert_above_anchor(n, 200);
}
for &n in &[100usize, 1_000, 10_000] {
bench_expand_holds_edge(n, 40);
}
for &chars in &[1_000usize, 10_000, 50_000] {
bench_redraw_big_text(chars, 10);
}
}
-7
View File
@@ -1,7 +0,0 @@
[package]
name = "cargo-iris"
version.workspace = true
edition.workspace = true
[dependencies]
cargo_metadata = "0.23.1"
@@ -1,50 +0,0 @@
package dev.iris.android;
import android.app.Activity;
import android.content.Context;
import android.view.Gravity;
import android.widget.ScrollView;
import android.widget.TextView;
import org.linebender.android.rustview.RustView;
public final class IrisView extends RustView {
@Override
protected native long newViewPeer(Context context);
native void applyWindowInsetsNative(
long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible);
native void unregisterInsetsNative(long peer);
public IrisView(Context context) {
super(context);
}
void applyWindowInsets(
int left, int top, int right, int bottom, int imeBottom, int imeVisible) {
applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible);
}
@Override
protected void onDetachedFromWindow() {
unregisterInsetsNative(mViewPeer);
super.onDetachedFromWindow();
}
void showRendererError(String report) {
Context context = getContext();
if (!(context instanceof Activity)) {
return;
}
Activity activity = (Activity) context;
TextView text = new TextView(activity);
text.setText(report);
text.setTextIsSelectable(true);
text.setGravity(Gravity.TOP | Gravity.START);
int pad = (int) (16 * activity.getResources().getDisplayMetrics().density);
text.setPadding(pad, pad, pad, pad);
ScrollView scroll = new ScrollView(activity);
scroll.addView(text);
activity.setContentView(scroll);
}
}
@@ -1,81 +0,0 @@
package dev.iris.android;
import android.app.Activity;
import android.os.Build;
import android.os.Bundle;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.widget.FrameLayout;
import java.util.List;
public final class MainActivity extends Activity {
static {
System.loadLibrary("IRIS_NATIVE_LIBRARY");
}
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
IrisView view = new IrisView(this);
view.setLayoutParams(new FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
view.setFocusable(true);
view.setFocusableInTouchMode(true);
FrameLayout layout = new FrameLayout(this);
layout.addView(view);
setContentView(layout);
view.requestFocus();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
drawBehindSystemBars();
view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback(
WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {
@Override
public WindowInsets onProgress(
WindowInsets insets, List<WindowInsetsAnimation> running) {
sendInsets(view, insets);
return insets;
}
@Override
public void onEnd(WindowInsetsAnimation animation) {
WindowInsets settled = view.getRootWindowInsets();
if (settled != null) {
sendInsets(view, settled);
}
}
});
}
view.setOnApplyWindowInsetsListener((v, insets) -> {
sendInsets((IrisView) v, insets);
return insets;
});
}
// Android 15 deprecated this API in favor of edge-to-edge enforcement,
// but API 30 through 34 still need it and Iris supports that whole range.
@SuppressWarnings("deprecation")
private void drawBehindSystemBars() {
getWindow().setDecorFitsSystemWindows(false);
}
// API 29 has no replacement for these four system-window inset getters;
// the API 30 methods cannot run on Iris's supported minimum.
@SuppressWarnings("deprecation")
private static void sendInsets(IrisView view, WindowInsets insets) {
int imeBottom = 0;
int imeVisible = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom;
imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0;
}
view.applyWindowInsets(
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom(),
imeBottom,
imeVisible);
}
}
@@ -1,153 +0,0 @@
package org.linebender.android.rustview;
import android.os.Bundle;
import android.os.Handler;
import android.view.KeyEvent;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputContentInfo;
class RustInputConnection implements InputConnection {
private final RustView mView;
RustInputConnection(RustView view) {
mView = view;
}
private long getViewPeer() {
return mView.mViewPeer;
}
@Override
public CharSequence getTextBeforeCursor(int n, int flags) {
return mView.getTextBeforeCursorNative(getViewPeer(), n);
}
@Override
public CharSequence getTextAfterCursor(int n, int flags) {
return mView.getTextAfterCursorNative(getViewPeer(), n);
}
@Override
public CharSequence getSelectedText(int flags) {
return mView.getSelectedTextNative(getViewPeer());
}
@Override
public int getCursorCapsMode(int reqModes) {
return mView.getCursorCapsModeNative(getViewPeer(), reqModes);
}
@Override
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
return null;
}
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength);
}
@Override
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength);
}
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition);
}
@Override
public boolean setComposingRegion(int start, int end) {
return mView.setComposingRegionNative(getViewPeer(), start, end);
}
@Override
public boolean finishComposingText() {
return mView.finishComposingTextNative(getViewPeer());
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition);
}
@Override
public boolean commitCompletion(CompletionInfo text) {
return false;
}
@Override
public boolean commitCorrection(CorrectionInfo correctionInfo) {
return false;
}
@Override
public boolean setSelection(int start, int end) {
return mView.setSelectionNative(getViewPeer(), start, end);
}
@Override
public boolean performEditorAction(int editorAction) {
return mView.performEditorActionNative(getViewPeer(), editorAction);
}
@Override
public boolean performContextMenuAction(int id) {
return mView.performContextMenuActionNative(getViewPeer(), id);
}
@Override
public boolean beginBatchEdit() {
return mView.beginBatchEditNative(getViewPeer());
}
@Override
public boolean endBatchEdit() {
return mView.endBatchEditNative(getViewPeer());
}
@Override
public boolean sendKeyEvent(KeyEvent event) {
return mView.inputConnectionSendKeyEventNative(getViewPeer(), event);
}
@Override
public boolean clearMetaKeyStates(int states) {
return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states);
}
@Override
public boolean reportFullscreenMode(boolean enabled) {
return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled);
}
@Override
public boolean performPrivateCommand(String action, Bundle data) {
return false;
}
@Override
public boolean requestCursorUpdates(int cursorUpdateMode) {
return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode);
}
@Override
public Handler getHandler() {
return null;
}
@Override
public void closeConnection() {
mView.closeInputConnectionNative(getViewPeer());
}
@Override
public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) {
return false;
}
}
@@ -1,287 +0,0 @@
package org.linebender.android.rustview;
import android.content.Context;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.Choreographer;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeProvider;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
public abstract class RustView extends SurfaceView
implements SurfaceHolder.Callback, Choreographer.FrameCallback {
// Vendored from android-view bec6c62. The only local change is `protected`,
// allowing IrisView to forward insets through this native peer.
protected final long mViewPeer;
final InputMethodManager mInputMethodManager;
protected abstract long newViewPeer(Context context);
public RustView(Context context) {
super(context);
mViewPeer = newViewPeer(context);
getHolder().addCallback(this);
mInputMethodManager =
(InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
}
private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec);
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec);
if (result != null) {
setMeasuredDimension(result[0], result[1]);
} else {
super.onMeasure(widthSpec, heightSpec);
}
}
private native void onLayoutNative(
long peer, boolean changed, int left, int top, int right, int bottom);
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
onLayoutNative(mViewPeer, changed, left, top, right, bottom);
super.onLayout(changed, left, top, right, bottom);
}
private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh);
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
onSizeChangedNative(mViewPeer, w, h, oldw, oldh);
super.onSizeChanged(w, h, oldw, oldh);
}
private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event);
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event);
}
private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event);
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event);
}
private native boolean onTrackballEventNative(long peer, MotionEvent event);
@Override
public boolean onTrackballEvent(MotionEvent event) {
return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event);
}
private native boolean onTouchEventNative(long peer, MotionEvent event);
@Override
public boolean onTouchEvent(MotionEvent event) {
return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event);
}
private native boolean onGenericMotionEventNative(long peer, MotionEvent event);
@Override
public boolean onGenericMotionEvent(MotionEvent event) {
return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event);
}
private native boolean onHoverEventNative(long peer, MotionEvent event);
@Override
public boolean onHoverEvent(MotionEvent event) {
return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event);
}
private native void onFocusChangedNative(
long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect);
@Override
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect);
}
private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus);
@Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
super.onWindowFocusChanged(hasWindowFocus);
onWindowFocusChangedNative(mViewPeer, hasWindowFocus);
}
private native void onAttachedToWindowNative(long peer);
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
onAttachedToWindowNative(mViewPeer);
}
private native void onDetachedFromWindowNative(long peer);
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
onDetachedFromWindowNative(mViewPeer);
}
private native void onWindowVisibilityChangedNative(long peer, int visibility);
@Override
protected void onWindowVisibilityChanged(int visibility) {
super.onWindowVisibilityChanged(visibility);
onWindowVisibilityChangedNative(mViewPeer, visibility);
}
private native void surfaceCreatedNative(long peer, SurfaceHolder holder);
@Override
public void surfaceCreated(SurfaceHolder holder) {
surfaceCreatedNative(mViewPeer, holder);
}
private native void surfaceChangedNative(
long peer, SurfaceHolder holder, int format, int width, int height);
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
surfaceChangedNative(mViewPeer, holder, format, width, height);
}
private native void surfaceDestroyedNative(long peer, SurfaceHolder holder);
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
surfaceDestroyedNative(mViewPeer, holder);
}
void postFrameCallback() {
Choreographer c = Choreographer.getInstance();
c.removeFrameCallback(this);
c.postFrameCallback(this);
}
void removeFrameCallback() {
Choreographer.getInstance().removeFrameCallback(this);
}
private native void doFrameNative(long peer, long frameTimeNanos);
@Override
public void doFrame(long frameTimeNanos) {
doFrameNative(mViewPeer, frameTimeNanos);
}
private native void delayedCallbackNative(long peer);
private final Runnable mDelayedCallback =
new Runnable() {
@Override
public void run() {
delayedCallbackNative(mViewPeer);
}
};
boolean postDelayed(long delayMillis) {
return postDelayed(mDelayedCallback, delayMillis);
}
boolean removeDelayedCallbacks() {
return removeCallbacks(mDelayedCallback);
}
private native boolean hasAccessibilityNodeProviderNative(long peer);
private native AccessibilityNodeInfo createAccessibilityNodeInfoNative(
long peer, int virtualViewId);
private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId);
private native boolean performAccessibilityActionNative(
long peer, int virtualViewId, int action, Bundle arguments);
@Override
public AccessibilityNodeProvider getAccessibilityNodeProvider() {
if (!hasAccessibilityNodeProviderNative(mViewPeer)) {
return super.getAccessibilityNodeProvider();
}
return new AccessibilityNodeProvider() {
@Override
public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) {
return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId);
}
@Override
public AccessibilityNodeInfo findFocus(int focusType) {
return accessibilityFindFocusNative(mViewPeer, focusType);
}
@Override
public boolean performAction(int virtualViewId, int action, Bundle arguments) {
return performAccessibilityActionNative(
mViewPeer, virtualViewId, action, arguments);
}
};
}
private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs);
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) {
return null;
}
return new RustInputConnection(this);
}
native String getTextBeforeCursorNative(long peer, int n);
native String getTextAfterCursorNative(long peer, int n);
native String getSelectedTextNative(long peer);
native int getCursorCapsModeNative(long peer, int reqModes);
native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength);
native boolean deleteSurroundingTextInCodePointsNative(
long peer, int beforeLength, int afterLength);
native boolean setComposingTextNative(long peer, String text, int newCursorPosition);
native boolean setComposingRegionNative(long peer, int start, int end);
native boolean finishComposingTextNative(long peer);
native boolean commitTextNative(long peer, String text, int newCursorPosition);
native boolean setSelectionNative(long peer, int start, int end);
native boolean performEditorActionNative(long peer, int editorAction);
native boolean performContextMenuActionNative(long peer, int id);
native boolean beginBatchEditNative(long peer);
native boolean endBatchEditNative(long peer);
native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event);
native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states);
native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled);
native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode);
native void closeInputConnectionNative(long peer);
}
-13
View File
@@ -1,13 +0,0 @@
mod package;
use std::{env, process::ExitCode};
fn main() -> ExitCode {
match package::run(env::args().skip(1).collect()) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("cargo iris: {error}");
ExitCode::FAILURE
}
}
}
-902
View File
@@ -1,902 +0,0 @@
use cargo_metadata::{CrateType, MetadataCommand, Package, Target, TargetKind};
use std::{
env,
ffi::OsStr,
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
};
const MIN_SDK: u32 = 29;
const ACTIVITY: &str = "dev.iris.android.MainActivity";
pub fn run(mut args: Vec<String>) -> Result<(), String> {
if args.first().is_some_and(|arg| arg == "iris") {
args.remove(0);
}
let command = args.first().map(String::as_str).unwrap_or("help");
if matches!(command, "help" | "--help" | "-h") {
print_help();
return Ok(());
}
if !matches!(command, "apk" | "run") {
return Err(format!(
"unknown command {command:?}; run `cargo iris --help`"
));
}
let options = Options::parse(&args[1..], command == "run")?;
let built = build(&options)?;
println!("{}", built.apk.display());
if command == "run" {
install_and_run(&built, options.device.as_deref().unwrap())?;
}
Ok(())
}
fn print_help() {
println!(
"Build an Iris application or example as an installable Android APK.\n\n\
Usage:\n cargo iris apk [OPTIONS]\n cargo iris run --device SERIAL [OPTIONS]\n\n\
Options:\n --manifest-path PATH\n --package NAME\n --example NAME\n --abi arm64-v8a|x86_64\n --release\n\
\x20 --application-id ID\n --label TEXT\n --keystore PATH --key-alias ALIAS\n\n\
Release signing passwords come from IRIS_KEYSTORE_PASSWORD and, when different,\n\
IRIS_KEY_PASSWORD. Iris uses the Android SDK and emulator/device supplied by you."
);
}
#[derive(Default)]
struct Options {
manifest_path: Option<PathBuf>,
package: Option<String>,
example: Option<String>,
abi: String,
release: bool,
application_id: Option<String>,
label: Option<String>,
keystore: Option<PathBuf>,
key_alias: Option<String>,
device: Option<String>,
}
impl Options {
fn parse(args: &[String], run: bool) -> Result<Self, String> {
let mut options = Self {
abi: "arm64-v8a".into(),
..Self::default()
};
let mut i = 0;
while i < args.len() {
let value = |name: &str, i: &mut usize| -> Result<String, String> {
*i += 1;
args.get(*i)
.cloned()
.ok_or_else(|| format!("{name} needs a value"))
};
match args[i].as_str() {
"--manifest-path" => {
options.manifest_path = Some(value("--manifest-path", &mut i)?.into())
}
"--package" => options.package = Some(value("--package", &mut i)?),
"--example" => options.example = Some(value("--example", &mut i)?),
"--abi" => options.abi = value("--abi", &mut i)?,
"--application-id" => {
options.application_id = Some(value("--application-id", &mut i)?)
}
"--label" => options.label = Some(value("--label", &mut i)?),
"--keystore" => options.keystore = Some(value("--keystore", &mut i)?.into()),
"--key-alias" => options.key_alias = Some(value("--key-alias", &mut i)?),
"--device" => options.device = Some(value("--device", &mut i)?),
"--release" => options.release = true,
other => return Err(format!("unknown option {other:?}")),
}
i += 1;
}
if !matches!(options.abi.as_str(), "arm64-v8a" | "x86_64") {
return Err(format!(
"unsupported ABI {:?}; use arm64-v8a or x86_64",
options.abi
));
}
if run && options.device.is_none() {
return Err(
"`cargo iris run` needs --device SERIAL; Iris never chooses or starts an emulator"
.into(),
);
}
if options.release && (options.keystore.is_none() || options.key_alias.is_none()) {
return Err("a release APK needs --keystore PATH and --key-alias ALIAS".into());
}
Ok(options)
}
}
struct Built {
apk: PathBuf,
application_id: String,
sdk: Sdk,
}
fn build(options: &Options) -> Result<Built, String> {
let mut metadata = MetadataCommand::new();
if let Some(path) = &options.manifest_path {
metadata.manifest_path(path);
}
let metadata = metadata
.exec()
.map_err(|error| format!("could not read Cargo metadata: {error}"))?;
let package = select_package(
&metadata.packages,
metadata.root_package(),
options.package.as_deref(),
)?;
let target = select_target(package, options.example.as_deref())?;
let sdk = Sdk::find()?;
let application_id = options
.application_id
.clone()
.or_else(|| {
options
.example
.is_none()
.then(|| metadata_string(package, "application-id"))
.flatten()
})
.unwrap_or_else(|| default_application_id(&package.name, options.example.as_deref()));
validate_application_id(&application_id)?;
let label = options
.label
.clone()
.or_else(|| {
options
.example
.is_none()
.then(|| metadata_string(package, "label"))
.flatten()
})
.unwrap_or_else(|| {
options
.example
.clone()
.unwrap_or_else(|| package.name.to_string())
});
let variant = if options.release { "release" } else { "debug" };
let artifact = options.example.as_ref().map_or_else(
|| package.name.to_string(),
|example| format!("{}-{example}", package.name),
);
let output = metadata
.target_directory
.as_std_path()
.join("iris-android")
.join(&artifact)
.join(variant)
.join(&options.abi);
recreate(&output)?;
let staging = output.join("staging");
let staging_cleanup = RemoveDirOnDrop(&staging);
let native = staging.join("native");
let (build_manifest, library_name) = if options.example.is_some() {
let wrapper = metadata
.target_directory
.as_std_path()
.join("iris-android")
.join("example-wrappers")
.join(&artifact);
materialize_example_wrapper(&metadata.packages, package, target, &wrapper)?
} else {
(
package.manifest_path.as_std_path().to_path_buf(),
target.name.clone(),
)
};
let mut cargo = Command::new("cargo");
cargo
.args(["ndk", "-t", &options.abi, "-P", &MIN_SDK.to_string(), "-o"])
.arg(&native)
.arg("build")
.arg("--lib")
.arg("--manifest-path")
.arg(&build_manifest)
.env("CARGO_TARGET_DIR", metadata.target_directory.as_std_path());
if options.release {
cargo.arg("--release");
}
run_command(
&mut cargo,
"Rust Android library",
"install cargo-ndk with `cargo install cargo-ndk`",
)?;
let library = native
.join(&options.abi)
.join(format!("lib{library_name}.so"));
if !library.is_file() {
return Err(format!("cargo-ndk did not produce {}", library.display()));
}
let classes = staging.join("classes");
fs::create_dir_all(&classes).map_err(io_error("create Java output", &classes))?;
let sources = materialize_host(&staging, &library_name)?;
let java_files = files_with_extension(&sources, "java")?;
let mut javac = Command::new("javac");
javac
.args(["--release", "17", "-classpath"])
.arg(&sdk.android_jar)
.arg("-d")
.arg(&classes)
.args(&java_files);
run_command(
&mut javac,
"Iris Android Java host",
"install a JDK containing javac",
)?;
let dex = staging.join("dex");
fs::create_dir_all(&dex).map_err(io_error("create DEX output", &dex))?;
let class_files = files_with_extension(&classes, "class")?;
let mut d8 = Command::new(&sdk.d8);
d8.args(["--min-api", &MIN_SDK.to_string(), "--lib"])
.arg(&sdk.android_jar)
.arg("--output")
.arg(&dex)
.args(&class_files);
if options.release {
d8.arg("--release");
} else {
d8.arg("--debug");
}
run_command(
&mut d8,
"Iris Android DEX",
"install Android SDK Build Tools",
)?;
let manifest = staging.join("AndroidManifest.xml");
fs::write(
&manifest,
manifest_xml(&application_id, &label, &library_name, sdk.api),
)
.map_err(io_error("write Android manifest", &manifest))?;
let unsigned = staging.join("unsigned.apk");
let mut aapt = Command::new(&sdk.aapt2);
aapt.arg("link")
.arg("-o")
.arg(&unsigned)
.arg("-I")
.arg(&sdk.android_jar)
.arg("--manifest")
.arg(&manifest)
.args([
"--min-sdk-version",
&MIN_SDK.to_string(),
"--target-sdk-version",
&sdk.api.to_string(),
]);
run_command(
&mut aapt,
"Android resources",
"install Android SDK Build Tools",
)?;
append_payload(
&unsigned,
&staging,
&dex.join("classes.dex"),
&library,
&options.abi,
&library_name,
)?;
let aligned = staging.join("aligned.apk");
let mut zipalign = Command::new(&sdk.zipalign);
zipalign
.args(["-P", "16", "-f", "4"])
.arg(&unsigned)
.arg(&aligned);
run_command(
&mut zipalign,
"APK alignment",
"install Android SDK Build Tools",
)?;
let apk = output.join(format!("{artifact}-{variant}.apk"));
sign(&sdk, options, &aligned, &apk)?;
verify(&sdk, &apk)?;
fs::remove_dir_all(&staging).map_err(io_error("remove APK staging directory", &staging))?;
std::mem::forget(staging_cleanup);
Ok(Built {
apk,
application_id,
sdk,
})
}
fn select_package<'a>(
packages: &'a [Package],
root: Option<&'a Package>,
wanted: Option<&str>,
) -> Result<&'a Package, String> {
if let Some(wanted) = wanted {
return packages
.iter()
.find(|package| package.name == wanted)
.ok_or_else(|| format!("Cargo workspace has no package named {wanted:?}"));
}
root.ok_or_else(|| {
"this is a virtual workspace; select an application with --package NAME".into()
})
}
fn select_target<'a>(package: &'a Package, example: Option<&str>) -> Result<&'a Target, String> {
if let Some(example) = example {
return package
.targets
.iter()
.find(|target| {
target.name == example
&& target.kind.iter().any(|kind| kind == &TargetKind::Example)
})
.ok_or_else(|| {
format!(
"package {} has no example named {example:?}; use one of: {}",
package.name,
package
.targets
.iter()
.filter(|target| target
.kind
.iter()
.any(|kind| kind == &TargetKind::Example))
.map(|target| target.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
});
}
package
.targets
.iter()
.find(|target| {
target
.crate_types
.iter()
.any(|kind| kind == &CrateType::CDyLib)
})
.ok_or_else(|| {
format!(
"package {} has no cdylib target; add `[lib] crate-type = [\"cdylib\", \"rlib\"]` to {}",
package.name, package.manifest_path
)
})
}
fn materialize_example_wrapper(
packages: &[Package],
package: &Package,
example: &Target,
wrapper: &Path,
) -> Result<(PathBuf, String), String> {
let android_source = example
.src_path
.as_std_path()
.parent()
.unwrap()
.join("android.rs");
if !android_source.is_file() {
return Err(format!(
"example {:?} has no Android entry point at {}; put shared code in lib.rs and add sibling desktop.rs and android.rs entries",
example.name,
android_source.display()
));
}
let iris = packages
.iter()
.find(|dependency| dependency.name == "iris")
.ok_or_else(|| {
format!(
"example {:?} does not depend on Iris; add `iris` to {}",
example.name, package.manifest_path
)
})?;
let source_dir = wrapper.join("src");
fs::create_dir_all(&source_dir)
.map_err(io_error("create Android example wrapper", &source_dir))?;
let library_name = format!(
"iris_android_{}_{}",
identifier_segment(&package.name),
identifier_segment(&example.name)
);
let iris_root = iris.manifest_path.parent().unwrap();
let manifest = format!(
"[package]\nname = {name:?}\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\
[lib]\nname = {library_name:?}\ncrate-type = [\"cdylib\", \"rlib\"]\n\n\
[dependencies]\niris = {{ path = {iris_root:?} }}\n\n[workspace]\n\n\
[profile.dev]\ndebug = \"line-tables-only\"\n",
name = format!("iris-android-{}-{}", package.name, example.name),
iris_root = iris_root.as_str(),
);
let manifest_path = wrapper.join("Cargo.toml");
write_if_changed(&manifest_path, &manifest)?;
let source = format!(
"#[path = {:?}]\nmod example;\n",
android_source.to_string_lossy()
);
let source_path = source_dir.join("lib.rs");
write_if_changed(&source_path, &source)?;
Ok((manifest_path, library_name))
}
fn write_if_changed(path: &Path, contents: &str) -> Result<bool, String> {
match fs::read(path) {
Ok(existing) if existing == contents.as_bytes() => return Ok(false),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("cannot read {}: {error}", path.display())),
}
fs::write(path, contents).map_err(io_error("write generated file", path))?;
Ok(true)
}
fn metadata_string(package: &Package, key: &str) -> Option<String> {
package
.metadata
.get("iris")?
.get("android")?
.get(key)?
.as_str()
.map(str::to_owned)
}
fn identifier_segment(value: &str) -> String {
value
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
fn default_application_id(package: &str, example: Option<&str>) -> String {
let package = identifier_segment(package);
match example {
Some(example) => format!("dev.iris.example.{package}.{}", identifier_segment(example)),
None => format!("dev.iris.app.{package}"),
}
}
fn validate_application_id(id: &str) -> Result<(), String> {
let valid = id.split('.').count() >= 2
&& id.split('.').all(|segment| {
!segment.is_empty()
&& segment.as_bytes()[0].is_ascii_alphabetic()
&& segment
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_')
});
if valid {
Ok(())
} else {
Err(format!(
"application ID {id:?} is invalid; use dot-separated Java identifiers"
))
}
}
struct Sdk {
api: u32,
android_jar: PathBuf,
aapt2: PathBuf,
d8: PathBuf,
zipalign: PathBuf,
apksigner: PathBuf,
adb: PathBuf,
}
/// Failed builds have no reusable staging output either; the next invocation
/// starts from scratch, so do not make a failure consume disk indefinitely.
struct RemoveDirOnDrop<'a>(&'a Path);
impl Drop for RemoveDirOnDrop<'_> {
fn drop(&mut self) {
let _ = fs::remove_dir_all(self.0);
}
}
impl Sdk {
fn find() -> Result<Self, String> {
let root = env::var_os("ANDROID_HOME")
.or_else(|| env::var_os("ANDROID_SDK_ROOT"))
.map(PathBuf::from)
.ok_or_else(|| "ANDROID_HOME is unset; point it at your Android SDK".to_string())?;
let (api, platform) = newest_numbered(&root.join("platforms"), "android-")?;
let (_, tools) = newest_numbered(&root.join("build-tools"), "")?;
let executable = |name: &str, windows_extension: &str| {
tools.join(if cfg!(windows) {
format!("{name}.{windows_extension}")
} else {
name.to_string()
})
};
let sdk = Self {
android_jar: platform.join("android.jar"),
aapt2: executable("aapt2", "exe"),
d8: executable("d8", "bat"),
zipalign: executable("zipalign", "exe"),
apksigner: executable("apksigner", "bat"),
adb: root
.join("platform-tools")
.join(if cfg!(windows) { "adb.exe" } else { "adb" }),
api,
};
for (name, path) in [
("android.jar", &sdk.android_jar),
("aapt2", &sdk.aapt2),
("d8", &sdk.d8),
("zipalign", &sdk.zipalign),
("apksigner", &sdk.apksigner),
] {
if !path.is_file() {
return Err(format!(
"Android SDK is missing {name} at {}; install a platform and Build Tools",
path.display()
));
}
}
Ok(sdk)
}
}
fn newest_numbered(parent: &Path, prefix: &str) -> Result<(u32, PathBuf), String> {
let entries = fs::read_dir(parent).map_err(|error| {
format!(
"cannot read {}: {error}; install the required Android SDK component",
parent.display()
)
})?;
entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name();
let version = name
.to_string_lossy()
.strip_prefix(prefix)?
.split('.')
.map(str::parse::<u32>)
.collect::<Result<Vec<_>, _>>()
.ok()?;
Some((version, entry.path()))
})
.max_by(|(left, _), (right, _)| left.cmp(right))
.map(|(version, path)| (version[0], path))
.ok_or_else(|| {
format!(
"no installed Android SDK component found under {}",
parent.display()
)
})
}
fn materialize_host(output: &Path, library: &str) -> Result<PathBuf, String> {
let root = output.join("java");
for (relative, contents) in HOST_FILES {
let path = root.join(relative);
fs::create_dir_all(path.parent().unwrap())
.map_err(io_error("create Java source directory", &path))?;
let contents = if relative.ends_with("MainActivity.java") {
contents.replace("IRIS_NATIVE_LIBRARY", library)
} else {
contents.to_string()
};
fs::write(&path, contents).map_err(io_error("write Java host source", &path))?;
}
Ok(root)
}
const HOST_FILES: &[(&str, &str)] = &[
(
"dev/iris/android/MainActivity.java",
include_str!("../android-host/dev/iris/android/MainActivity.java"),
),
(
"dev/iris/android/IrisView.java",
include_str!("../android-host/dev/iris/android/IrisView.java"),
),
(
"org/linebender/android/rustview/RustView.java",
include_str!("../android-host/org/linebender/android/rustview/RustView.java"),
),
(
"org/linebender/android/rustview/RustInputConnection.java",
include_str!("../android-host/org/linebender/android/rustview/RustInputConnection.java"),
),
];
fn manifest_xml(application_id: &str, label: &str, library: &str, target_sdk: u32) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="{}" android:versionCode="1" android:versionName="0.1.0">
<uses-sdk android:minSdkVersion="{MIN_SDK}" android:targetSdkVersion="{target_sdk}" />
<application android:allowBackup="true" android:extractNativeLibs="false" android:label="{}" android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity android:name="{ACTIVITY}" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:exported="true" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="{}" />
</activity>
</application>
</manifest>
"#,
xml_escape(application_id),
xml_escape(label),
xml_escape(library)
)
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn files_with_extension(root: &Path, extension: &str) -> Result<Vec<PathBuf>, String> {
fn visit(dir: &Path, extension: &str, output: &mut Vec<PathBuf>) -> Result<(), String> {
for entry in fs::read_dir(dir).map_err(io_error("read directory", dir))? {
let path = entry
.map_err(|error| format!("cannot read entry under {}: {error}", dir.display()))?
.path();
if path.is_dir() {
visit(&path, extension, output)?;
} else if path.extension() == Some(OsStr::new(extension)) {
output.push(path);
}
}
Ok(())
}
let mut files = Vec::new();
visit(root, extension, &mut files)?;
files.sort();
Ok(files)
}
fn append_payload(
apk: &Path,
output: &Path,
dex: &Path,
library: &Path,
abi: &str,
library_name: &str,
) -> Result<(), String> {
let payload = output.join("payload");
let native_dir = payload.join("lib").join(abi);
fs::create_dir_all(&native_dir).map_err(io_error("create APK payload", &native_dir))?;
fs::copy(dex, payload.join("classes.dex")).map_err(io_error("stage classes.dex", dex))?;
let native_name = format!("lib{library_name}.so");
fs::copy(library, native_dir.join(&native_name))
.map_err(io_error("stage native library", library))?;
// `jar` is part of the JDK already needed for javac. Storing the native
// library uncompressed lets zipalign give it the 16 KiB page alignment
// required by current Android devices.
let mut jar = Command::new("jar");
jar.args(["--update", "--file"])
.arg(apk)
.args(["--no-manifest", "--no-compress", "-C"])
.arg(&payload)
.arg("classes.dex")
.arg("-C")
.arg(&payload)
.arg("lib");
run_command(
&mut jar,
"APK native payload",
"install a JDK containing jar",
)
}
fn sign(sdk: &Sdk, options: &Options, input: &Path, output: &Path) -> Result<(), String> {
let (keystore, alias, store_password, key_password) = if options.release {
let store = env::var("IRIS_KEYSTORE_PASSWORD")
.map_err(|_| "IRIS_KEYSTORE_PASSWORD is unset for release signing".to_string())?;
let key = env::var("IRIS_KEY_PASSWORD").unwrap_or_else(|_| store.clone());
(
options.keystore.clone().unwrap(),
options.key_alias.clone().unwrap(),
store,
key,
)
} else {
let home = env::var_os("HOME")
.ok_or_else(|| "HOME is unset; cannot locate the Android debug keystore".to_string())?;
let keystore = PathBuf::from(home).join(".android/debug.keystore");
ensure_debug_keystore(&keystore)?;
(
keystore,
"androiddebugkey".into(),
"android".into(),
"android".into(),
)
};
let mut command = Command::new(&sdk.apksigner);
// `install_and_run` uses `--no-streaming`, so it cannot consume the separate
// v4 `.idsig` file and retaining that sidecar beside the APK serves no caller.
command
.arg("sign")
.args([
"--v4-signing-enabled",
"false",
"--ks-pass",
"env:IRIS_APK_STORE_PASSWORD",
"--key-pass",
"env:IRIS_APK_KEY_PASSWORD",
"--ks-key-alias",
])
.arg(alias)
.arg("--ks")
.arg(keystore)
.arg("--out")
.arg(output)
.arg(input)
.env("IRIS_APK_STORE_PASSWORD", store_password)
.env("IRIS_APK_KEY_PASSWORD", key_password);
run_command(
&mut command,
"APK signing",
"check the keystore, alias, and signing passwords",
)
}
fn ensure_debug_keystore(path: &Path) -> Result<(), String> {
if path.is_file() {
return Ok(());
}
fs::create_dir_all(path.parent().unwrap())
.map_err(io_error("create Android configuration directory", path))?;
let mut keytool = Command::new("keytool");
keytool.args(["-genkeypair", "-keystore"]).arg(path).args([
"-storepass",
"android",
"-alias",
"androiddebugkey",
"-keypass",
"android",
"-dname",
"CN=Android Debug,O=Android,C=US",
"-keyalg",
"RSA",
"-keysize",
"2048",
"-validity",
"10000",
]);
run_command(
&mut keytool,
"Android debug key",
"install a JDK containing keytool",
)
}
fn verify(sdk: &Sdk, apk: &Path) -> Result<(), String> {
let mut align = Command::new(&sdk.zipalign);
align.args(["-c", "-P", "16", "4"]).arg(apk);
run_command(
&mut align,
"APK alignment verification",
"this indicates a cargo-iris packaging defect",
)?;
let mut sign = Command::new(&sdk.apksigner);
sign.args(["verify", "--verbose"]).arg(apk);
run_command(
&mut sign,
"APK signature verification",
"this indicates a cargo-iris signing defect",
)
}
fn install_and_run(built: &Built, device: &str) -> Result<(), String> {
if !built.sdk.adb.is_file() {
return Err(format!(
"Android SDK is missing adb at {}; install Platform Tools",
built.sdk.adb.display()
));
}
let mut install = Command::new(&built.sdk.adb);
install
.args(["-s", device, "install", "--no-streaming", "-r"])
.arg(&built.apk);
run_command(
&mut install,
"APK install",
"check that the selected device is connected and authorized",
)?;
let component = format!("{}/{}", built.application_id, ACTIVITY);
let mut launch = Command::new(&built.sdk.adb);
launch.args(["-s", device, "shell", "am", "start", "-n", &component]);
run_command(
&mut launch,
"APK launch",
"check the package activity in the generated APK",
)
}
fn recreate(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_dir_all(path).map_err(io_error("clear prior APK output directory", path))?;
}
fs::create_dir_all(path).map_err(io_error("create APK output directory", path))
}
fn run_command(command: &mut Command, thing: &str, fix: &str) -> Result<(), String> {
command.stdin(Stdio::null());
let status = command
.status()
.map_err(|error| format!("could not start {thing}: {error}; {fix}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{thing} failed with {status}; {fix}"))
}
}
fn io_error<'a>(action: &'a str, path: &'a Path) -> impl FnOnce(std::io::Error) -> String + 'a {
move |error| format!("cannot {action} {}: {error}", path.display())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn application_ids_are_validated() {
assert!(validate_application_id("dev.iris.app.demo_2").is_ok());
assert!(validate_application_id("one").is_err());
assert!(validate_application_id("dev.2demo").is_err());
assert!(validate_application_id("dev.iris.bad-name").is_err());
assert_eq!(
default_application_id("demo-app", Some("color-picker")),
"dev.iris.example.demo_app.color_picker"
);
}
#[test]
fn manifest_values_are_escaped() {
let manifest = manifest_xml("dev.iris.demo", "A & <demo>", "demo", 37);
assert!(manifest.contains("A &amp; &lt;demo&gt;"));
assert!(manifest.contains("android:minSdkVersion=\"29\""));
}
#[test]
fn an_unchanged_generated_file_is_not_rewritten() {
let root = env::temp_dir().join(format!("cargo-iris-write-test-{}", std::process::id()));
let path = root.join("generated.rs");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
assert!(write_if_changed(&path, "first").unwrap());
assert!(!write_if_changed(&path, "first").unwrap());
assert!(write_if_changed(&path, "second").unwrap());
assert_eq!(fs::read_to_string(&path).unwrap(), "second");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn failed_build_staging_is_removed_when_its_scope_ends() {
let root = env::temp_dir().join(format!("cargo-iris-staging-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
fs::write(root.join("intermediate"), "not reusable").unwrap();
{
let _cleanup = RemoveDirOnDrop(&root);
}
assert!(!root.exists());
}
}
+2 -5
View File
@@ -4,12 +4,9 @@ version.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
winit = { workspace = true }
wgpu = { workspace = true } wgpu = { workspace = true }
# Keeps renderer creation synchronous while retrieving wgpu's async error scope.
pollster = { workspace = true }
bytemuck ={ workspace = true } bytemuck ={ workspace = true }
image = { workspace = true } image = { workspace = true }
parley = { workspace = true } cosmic-text = { workspace = true }
swash = { workspace = true }
fxhash = { workspace = true } fxhash = { workspace = true }
accesskit = { workspace = true }
+13 -11
View File
@@ -1,22 +1,24 @@
use crate::{UiRsc, WeakWidget, WidgetIdFn, WidgetLike}; use crate::{HasUi, StateLike, WidgetIdFn, WidgetLike, WidgetRef};
pub trait WidgetAttr<Rsc, W: ?Sized> { pub trait WidgetAttr<State, W: ?Sized> {
type Input; type Input;
fn run(rsc: &mut Rsc, id: WeakWidget<W>, input: Self::Input); fn run(state: &mut State, id: WidgetRef<W>, input: Self::Input);
} }
pub trait Attrable<Rsc, W: ?Sized, Tag> { pub trait Attrable<State, W: ?Sized, Tag> {
fn attr<A: WidgetAttr<Rsc, W>>(self, input: A::Input) -> impl WidgetIdFn<Rsc, W>; fn attr<A: WidgetAttr<State, W>>(self, input: A::Input) -> impl WidgetIdFn<State, W>;
} }
impl<Rsc: UiRsc, WL: WidgetLike<Rsc, Tag>, Tag> Attrable<Rsc, WL::Widget, Tag> for WL { impl<State: HasUi + StateLike<State>, WL: WidgetLike<State, Tag>, Tag>
fn attr<A: WidgetAttr<Rsc, WL::Widget>>( Attrable<State, WL::Widget, Tag> for WL
{
fn attr<A: WidgetAttr<State, WL::Widget>>(
self, self,
input: A::Input, input: A::Input,
) -> impl WidgetIdFn<Rsc, WL::Widget> { ) -> impl WidgetIdFn<State, WL::Widget> {
|rsc| { |state| {
let id = self.add(rsc); let id = self.add(state);
A::run(rsc, id, input); A::run(state, id, input);
id id
} }
} }
-294
View File
@@ -1,294 +0,0 @@
use crate::{
UiRenderState, WidgetId,
util::{HashMap, HashSet},
};
use std::any::{Any, TypeId};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ControllerId {
host: WidgetId,
kind: TypeId,
}
impl ControllerId {
pub fn host(self) -> WidgetId {
self.host
}
pub fn is<C: 'static>(self) -> bool {
self.kind == TypeId::of::<C>()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Command {
Copy,
SelectAll,
Escape,
}
#[derive(Debug, Eq, PartialEq)]
pub enum CommandResult {
Unused,
Used,
Copy(String),
}
pub trait ControllerValue: Any {
fn into_any(self: Box<Self>) -> Box<dyn Any>;
}
impl<T: Any> ControllerValue for T {
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
pub trait Controller<Rsc>: ControllerValue {
fn command(&mut self, _command: Command, _rsc: &mut Rsc) -> CommandResult {
CommandResult::Unused
}
fn blocks_prior_input(&self) -> bool {
false
}
}
pub struct ControllerManager<Rsc> {
by_widget: HashMap<WidgetId, HashMap<TypeId, Box<dyn Controller<Rsc>>>>,
borrowed: HashSet<ControllerId>,
removed_while_borrowed: HashSet<WidgetId>,
command_target: Option<ControllerId>,
command_target_revision: u64,
command_boundary: Option<WidgetId>,
}
impl<Rsc> Default for ControllerManager<Rsc> {
fn default() -> Self {
Self {
by_widget: Default::default(),
borrowed: Default::default(),
removed_while_borrowed: Default::default(),
command_target: None,
command_target_revision: 0,
command_boundary: None,
}
}
}
impl<Rsc: 'static> ControllerManager<Rsc> {
#[track_caller]
pub fn register<C: Controller<Rsc>>(&mut self, host: WidgetId, controller: C) {
let kind = TypeId::of::<C>();
let id = ControllerId { host, kind };
assert!(
!self.borrowed.contains(&id),
"a controller cannot be replaced while it is handling input"
);
assert!(
!self.removed_while_borrowed.contains(&host),
"a controller cannot be attached to a removed widget"
);
let old = self
.by_widget
.entry(host)
.or_default()
.insert(kind, Box::new(controller));
assert!(
old.is_none(),
"a widget cannot have two controllers of type {}",
std::any::type_name::<C>()
);
}
pub fn id<C: Controller<Rsc>>(&self, host: WidgetId) -> Option<ControllerId> {
let kind = TypeId::of::<C>();
self.by_widget
.get(&host)?
.contains_key(&kind)
.then_some(ControllerId { host, kind })
}
pub fn nearest_id<C: Controller<Rsc>>(
&self,
mut origin: WidgetId,
render_state: &UiRenderState,
) -> Option<ControllerId> {
let kind = TypeId::of::<C>();
loop {
let candidate = ControllerId { host: origin, kind };
assert!(
!self.borrowed.contains(&candidate),
"a controller cannot re-enter itself while it is handling input"
);
if let Some(id) = self.id::<C>(origin) {
return Some(id);
}
origin = render_state.active.get(&origin)?.parent?;
}
}
pub fn path_to<C: Controller<Rsc>>(
&self,
mut origin: WidgetId,
render_state: &UiRenderState,
) -> Option<(ControllerId, Vec<WidgetId>)> {
let mut path = Vec::new();
loop {
path.push(origin);
if let Some(id) = self.id::<C>(origin) {
return Some((id, path));
}
origin = render_state.active.get(&origin)?.parent?;
}
}
pub fn take<C: Controller<Rsc>>(&mut self, id: ControllerId) -> Option<C> {
if id.kind != TypeId::of::<C>() {
return None;
}
assert!(
!self.borrowed.contains(&id),
"a controller cannot re-enter itself while it is handling input"
);
let boxed = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?;
self.borrowed.insert(id);
let boxed = boxed.into_any();
boxed.downcast().ok().map(|boxed| *boxed)
}
pub fn put<C: Controller<Rsc>>(&mut self, id: ControllerId, controller: C) {
debug_assert_eq!(id.kind, TypeId::of::<C>());
assert!(
self.borrowed.remove(&id),
"restored an unborrowed controller"
);
if self.finish_removed_host(id.host) {
return;
}
let old = self
.by_widget
.entry(id.host)
.or_default()
.insert(id.kind, Box::new(controller));
debug_assert!(old.is_none(), "a controller was re-entered while borrowed");
}
fn take_dyn(&mut self, id: ControllerId) -> Option<Box<dyn Controller<Rsc>>> {
assert!(
!self.borrowed.contains(&id),
"a controller cannot re-enter itself while it is handling input"
);
let controller = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?;
self.borrowed.insert(id);
Some(controller)
}
fn put_dyn(&mut self, id: ControllerId, controller: Box<dyn Controller<Rsc>>) {
assert!(
self.borrowed.remove(&id),
"restored an unborrowed controller"
);
if self.finish_removed_host(id.host) {
return;
}
let old = self
.by_widget
.entry(id.host)
.or_default()
.insert(id.kind, controller);
debug_assert!(old.is_none(), "a controller was re-entered while borrowed");
}
pub fn set_command_target(&mut self, target: Option<ControllerId>) {
self.command_target = target;
self.command_target_revision = self.command_target_revision.wrapping_add(1);
}
pub fn command_target(&self) -> Option<ControllerId> {
self.command_target
}
pub fn command_target_blocks_input(&self) -> bool {
let Some(id) = self.command_target else {
return false;
};
self.by_widget
.get(&id.host)
.and_then(|controllers| controllers.get(&id.kind))
.is_some_and(|controller| controller.blocks_prior_input())
}
pub(crate) fn command_target_revision(&self) -> u64 {
self.command_target_revision
}
pub(crate) fn command_boundary(&self) -> Option<WidgetId> {
self.command_boundary
}
pub(crate) fn is_below(
&self,
mut widget: WidgetId,
ancestor: WidgetId,
render_state: &UiRenderState,
) -> bool {
loop {
let Some(parent) = render_state
.active
.get(&widget)
.and_then(|active| active.parent)
else {
return false;
};
if parent == ancestor {
return true;
}
widget = parent;
}
}
pub(crate) fn set_command_boundary(&mut self, boundary: Option<WidgetId>) {
self.command_boundary = boundary;
}
pub fn remove(&mut self, host: WidgetId) {
self.by_widget.remove(&host);
if self.borrowed.iter().any(|id| id.host == host) {
self.removed_while_borrowed.insert(host);
}
if self.command_target.is_some_and(|id| id.host == host) {
self.command_target = None;
}
}
pub(crate) fn take_command_target(
&mut self,
) -> Option<(ControllerId, Box<dyn Controller<Rsc>>)> {
let id = self.command_target?;
match self.take_dyn(id) {
Some(controller) => Some((id, controller)),
None => {
self.command_target = None;
None
}
}
}
pub(crate) fn restore(&mut self, id: ControllerId, controller: Box<dyn Controller<Rsc>>) {
self.put_dyn(id, controller);
}
/// Returns true when a host disappeared during its controller callback,
/// in which case restoring the temporarily extracted value would revive
/// state belonging to a dead widget generation.
fn finish_removed_host(&mut self, host: WidgetId) -> bool {
if !self.removed_while_borrowed.contains(&host) {
return false;
}
if !self.borrowed.iter().any(|id| id.host == host) {
self.removed_while_borrowed.remove(&host);
}
true
}
}
+109 -11
View File
@@ -1,18 +1,116 @@
use crate::{HasEvents, WeakWidget, Widget}; use crate::{HasEvents, HasState, HasUi, StateLike, Ui, Widget, WidgetRef};
use std::ops::{Deref, DerefMut};
pub struct EventCtx<'a, Rsc: HasEvents, Data> { pub struct EventCtx<'a, State, Data> {
pub state: &'a mut Rsc::State, pub state: &'a mut State,
pub data: Data, pub data: &'a mut Data,
} }
pub struct EventIdCtx<'a, Rsc: HasEvents, Data, W: ?Sized> { pub struct EventIdCtx<'a, State, Data, W: ?Sized> {
pub widget: WeakWidget<W>, pub widget: WidgetRef<W>,
pub state: &'a mut Rsc::State, pub state: &'a mut State,
pub data: Data, pub data: &'a mut Data,
} }
impl<Rsc: HasEvents, Data, W: Widget> EventIdCtx<'_, Rsc, Data, W> { impl<State: HasUi, Data, W: ?Sized> Deref for EventIdCtx<'_, State, Data, W> {
pub fn widget<'a>(&self, rsc: &'a mut Rsc) -> &'a mut W { type Target = State;
&mut rsc.ui_mut().widgets[self.widget]
fn deref(&self) -> &Self::Target {
self.state
} }
} }
impl<State: HasUi, Data, W: ?Sized> DerefMut for EventIdCtx<'_, State, Data, W> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.state
}
}
impl<State: HasUi, Data, W: Widget> EventIdCtx<'_, State, Data, W> {
pub fn widget(&mut self) -> &mut W {
&mut self.state.get_mut()[self.widget]
}
}
impl<State: HasUi, Data, W: Widget> HasUi for EventIdCtx<'_, State, Data, W> {
fn get(&self) -> &Ui {
self.state.ui()
}
fn get_mut(&mut self) -> &mut Ui {
self.state.ui_mut()
}
}
impl<State: HasUi, Data, W: Widget> HasState for EventIdCtx<'_, State, Data, W> {
type State = State;
}
impl<State: HasEvents<State = State>, Data, W: Widget> HasEvents
for EventIdCtx<'_, State, Data, W>
{
fn get(&self) -> &super::EventManager<Self::State> {
self.state.events()
}
fn get_mut(&mut self) -> &mut super::EventManager<Self::State> {
self.state.events_mut()
}
}
impl<State, Data, W: Widget> StateLike<State> for EventIdCtx<'_, State, Data, W> {
fn as_state(&mut self) -> &mut State {
self.state
}
}
// fn test() {
// use crate::*;
// struct ClientRsc;
// impl<State, Data> HasUi for EventCtx<'_, State, Data> {
// fn get(&self) -> &Ui {
// todo!()
// }
//
// fn get_mut(&mut self) -> &mut Ui {
// todo!()
// }
// }
// fn on(_: impl for<'a> EventFn<ClientRsc, &'a mut i32>) {}
//
// pub trait WidgetLike<State: HasUi, Tag>: Sized {
// type Widget: Widget + ?Sized + std::marker::Unsize<dyn Widget>;
//
// fn add(self, state: &mut State) -> WidgetHandle<Self::Widget>;
//
// fn with_id<W2>(
// self,
// f: impl FnOnce(&mut State, WidgetHandle<Self::Widget>) -> WidgetHandle<W2>,
// ) -> impl WidgetIdFn<State, W2> {
// move |state| {
// let id = self.add(state);
// f(state, id)
// }
// }
//
// fn set_root(self, state: &mut State) {
// state.get_mut().root = Some(self.add(state));
// }
//
// fn handles(self, state: &mut State) -> WidgetHandles<Self::Widget> {
// self.add(state).handles()
// }
// }
//
// pub struct WidgetTag;
// impl<State: HasUi, W: Widget> WidgetLike<State, WidgetTag> for W {
// type Widget = W;
// fn add(self, state: &mut State) -> WidgetHandle<W> {
// state.get_mut().add_widget(self)
// }
// }
//
// on(move |ctx| {
// ().add(ctx);
// });
// }
+33 -56
View File
@@ -1,47 +1,44 @@
use crate::{ use crate::{
ActiveData, ControllerManager, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, ActiveData, Event, EventCtx, EventFn, EventIdCtx, EventLike, IdLike, LayerId, Widget,
IdLike, LayerId, WeakWidget, WidgetEventFn, WidgetId, WidgetEventFn, WidgetId, WidgetRef,
util::{HashMap, HashSet, TypeMap}, util::{HashMap, HashSet, TypeMap},
}; };
use std::{any::TypeId, rc::Rc}; use std::{any::TypeId, rc::Rc};
pub struct EventManager<Rsc> { pub struct EventManager<State> {
widget_to_types: HashMap<WidgetId, HashSet<TypeId>>, widget_to_types: HashMap<WidgetId, HashSet<TypeId>>,
types: TypeMap<dyn EventManagerLike<Rsc>>, types: TypeMap<dyn EventManagerLike<State>>,
pub controllers: ControllerManager<Rsc>,
} }
impl<Rsc: 'static> Default for EventManager<Rsc> { impl<State> Default for EventManager<State> {
fn default() -> Self { fn default() -> Self {
Self { Self {
widget_to_types: Default::default(), widget_to_types: Default::default(),
types: Default::default(), types: Default::default(),
controllers: Default::default(),
} }
} }
} }
impl<Rsc: HasEvents + 'static> EventManager<Rsc> { impl<State: 'static> EventManager<State> {
pub fn get_type<E: EventLike>(&mut self) -> &mut TypeEventManager<Rsc, E::Event> { pub fn get_type<E: EventLike>(&mut self) -> &mut TypeEventManager<State, E::Event> {
self.types.type_or_default() self.types.type_or_default()
} }
pub fn register<I: IdLike + 'static, E: EventLike>( pub fn register<W: Widget + ?Sized, E: EventLike>(
&mut self, &mut self,
id: I, id: WidgetRef<W>,
event: E, event: E,
f: impl for<'a> WidgetEventFn<Rsc, <E::Event as Event>::Data<'a>, I::Widget>, f: impl for<'a> WidgetEventFn<State, <E::Event as Event>::Data<'a>, W>,
) { ) {
let i = id.id();
self.get_type::<E>().register(id, event, f); self.get_type::<E>().register(id, event, f);
self.widget_to_types self.widget_to_types
.entry(i) .entry(id.id())
.or_default() .or_default()
.insert(Self::type_key::<E>()); .insert(Self::type_key::<E>());
} }
pub fn type_key<E: EventLike>() -> TypeId { pub fn type_key<E: EventLike>() -> TypeId {
TypeId::of::<TypeEventManager<Rsc, E::Event>>() TypeId::of::<TypeEventManager<State, E::Event>>()
} }
} }
@@ -51,12 +48,11 @@ pub trait EventsLike {
fn undraw(&mut self, active: &ActiveData); fn undraw(&mut self, active: &ActiveData);
} }
impl<Rsc: HasEvents + 'static> EventsLike for EventManager<Rsc> { impl<State: 'static> EventsLike for EventManager<State> {
fn remove(&mut self, id: WidgetId) { fn remove(&mut self, id: WidgetId) {
for t in self.widget_to_types.get(&id).into_flat_iter() { for t in self.widget_to_types.get(&id).into_flat_iter() {
self.types.get_mut(t).unwrap().remove(id); self.types.get_mut(t).unwrap().remove(id);
} }
self.controllers.remove(id);
} }
fn draw(&mut self, active: &ActiveData) { fn draw(&mut self, active: &ActiveData) {
@@ -78,15 +74,14 @@ pub trait EventManagerLike<State> {
fn undraw(&mut self, data: &ActiveData); fn undraw(&mut self, data: &ActiveData);
} }
type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>>); type EventData<State, E> = (E, Rc<dyn for<'a> EventFn<State, <E as Event>::Data<'a>>>);
pub struct TypeEventManager<Rsc: HasEvents, E: Event> { pub struct TypeEventManager<State, E: Event> {
// TODO: reduce visiblity!! // TODO: reduce visiblity!!
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>, pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
pub global: E::Global, map: HashMap<WidgetId, Vec<EventData<State, E>>>,
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
} }
impl<Rsc: HasEvents, E: Event> EventManagerLike<Rsc> for TypeEventManager<Rsc, E> { impl<State, E: Event> EventManagerLike<State> for TypeEventManager<State, E> {
fn remove(&mut self, id: WidgetId) { fn remove(&mut self, id: WidgetId) {
self.map.remove(&id); self.map.remove(&id);
for layer in self.active.values_mut() { for layer in self.active.values_mut() {
@@ -107,66 +102,48 @@ impl<Rsc: HasEvents, E: Event> EventManagerLike<Rsc> for TypeEventManager<Rsc, E
} }
} }
impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> { impl<State, E: Event> Default for TypeEventManager<State, E> {
fn default() -> Self { fn default() -> Self {
Self { Self {
active: Default::default(), active: Default::default(),
global: Default::default(),
map: Default::default(), map: Default::default(),
} }
} }
} }
impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> { impl<State: 'static, E: Event> TypeEventManager<State, E> {
fn register<I: IdLike + 'static>( fn register<W: Widget + ?Sized>(
&mut self, &mut self,
widget: I, widget: WidgetRef<W>,
event: impl EventLike<Event = E>, event: impl EventLike<Event = E>,
f: impl for<'a> WidgetEventFn<Rsc, E::Data<'a>, I::Widget>, f: impl for<'a> WidgetEventFn<State, E::Data<'a>, W>,
) { ) {
let event = event.into_event(); let event = event.into_event();
self.map.entry(widget.id()).or_default().push(( self.map.entry(widget.id()).or_default().push((
event, event,
Rc::new(move |ctx, rsc| { Rc::new(move |ctx| {
f( let mut test = EventIdCtx {
EventIdCtx { widget,
widget: WeakWidget::new(widget.id()),
state: ctx.state, state: ctx.state,
data: ctx.data, data: ctx.data,
}, };
rsc, f(&mut test);
);
}), }),
)); ));
} }
/// The event lists this widget was registered with (`register`'s
/// `event` argument, one per call), without running anything. Lets a
/// caller ask "would this widget's registrations match the current
/// state" separately from actually dispatching to it -- used by
/// `sense.rs` to decide whether a widget genuinely consumes a scroll
/// or press this frame (so a lower layer can still receive it if not)
/// without that decision being conflated with "the cursor happens to
/// be over it," which is all `run_fn` running something tells you.
pub fn registered(&self, id: WidgetId) -> impl Iterator<Item = &E> {
self.map.get(&id).into_iter().flatten().map(|(e, _)| e)
}
pub fn run_fn<'a>( pub fn run_fn<'a>(
&mut self, &mut self,
id: impl IdLike, id: impl IdLike,
) -> impl for<'b> FnOnce(EventCtx<'_, Rsc, E::Data<'b>>, &mut Rsc) + 'a { ) -> impl for<'b> FnOnce(EventCtx<'_, State, E::Data<'b>>) + 'a {
let fs = self.map.get(&id.id()).cloned().unwrap_or_default(); let fs = self.map.get(&id.id()).cloned().unwrap_or_default();
move |ctx, rsc| { move |ctx| {
for (e, f) in fs { for (e, f) in fs {
if let Some(data) = e.should_run(&ctx.data) { if e.should_run(ctx.data) {
f( f(&mut EventCtx {
EventCtx {
state: ctx.state, state: ctx.state,
data, data: ctx.data,
}, })
rsc,
)
} }
} }
} }
+9 -15
View File
@@ -1,20 +1,17 @@
mod controller;
mod ctx; mod ctx;
mod manager; mod manager;
mod rsc; mod rsc;
pub use controller::*;
pub use ctx::*; pub use ctx::*;
pub use manager::*; pub use manager::*;
pub use rsc::*; pub use rsc::*;
pub trait Event: Sized + 'static + Clone { pub trait Event: Sized + 'static + Clone {
type Data<'a>: Clone = (); type Data<'a> = ();
type State: Default = (); type State: Default = ();
type Global: Default = ();
#[allow(unused_variables)] #[allow(unused_variables)]
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> { fn should_run(&self, data: &mut Self::Data<'_>) -> bool {
Some(data.clone()) true
} }
} }
@@ -31,17 +28,14 @@ impl<E: Event> EventLike for E {
} }
} }
pub trait EventFn<Rsc: HasEvents, Data>: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static {} pub trait EventFn<State, Data>: Fn(&mut EventCtx<State, Data>) + 'static {}
impl<Rsc: HasEvents, F: Fn(EventCtx<Rsc, Data>, &mut Rsc) + 'static, Data> EventFn<Rsc, Data> impl<State, F: Fn(&mut EventCtx<State, Data>) + 'static, Data> EventFn<State, Data> for F {}
for F
{
}
pub trait WidgetEventFn<Rsc: HasEvents, Data, W: ?Sized>: pub trait WidgetEventFn<State, Data, W: ?Sized>:
Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static Fn(&mut EventIdCtx<State, Data, W>) + 'static
{ {
} }
impl<Rsc: HasEvents, F: Fn(EventIdCtx<Rsc, Data, W>, &mut Rsc) + 'static, Data, W: ?Sized> impl<State, F: Fn(&mut EventIdCtx<State, Data, W>) + 'static, Data, W: ?Sized>
WidgetEventFn<Rsc, Data, W> for F WidgetEventFn<State, Data, W> for F
{ {
} }
+21 -101
View File
@@ -1,121 +1,41 @@
use crate::{ use crate::{
Command, CommandResult, Controller, ControllerId, Event, EventCtx, EventLike, EventManager, Event, EventCtx, EventLike, EventManager, HasUi, IdLike, Widget, WidgetEventFn, WidgetRef,
IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn,
}; };
pub trait HasState: 'static { pub trait HasState {
type State; type State: HasUi;
} }
pub trait HasEvents: Sized + UiRsc + HasState { pub trait HasEvents: Sized + HasState + HasUi {
fn events(&self) -> &EventManager<Self>; fn get(&self) -> &EventManager<Self::State>;
fn events_mut(&mut self) -> &mut EventManager<Self>; fn get_mut(&mut self) -> &mut EventManager<Self::State>;
fn events(&self) -> &EventManager<Self::State> {
HasEvents::get(self)
}
fn events_mut(&mut self) -> &mut EventManager<Self::State> {
HasEvents::get_mut(self)
}
fn register_event<W: Widget + ?Sized, E: EventLike>( fn register_event<W: Widget + ?Sized, E: EventLike>(
&mut self, &mut self,
id: WeakWidget<W>, id: WidgetRef<W>,
event: E, event: E,
f: impl for<'a> WidgetEventFn<Self, <E::Event as Event>::Data<'a>, W>, f: impl for<'a> WidgetEventFn<Self::State, <E::Event as Event>::Data<'a>, W>,
) { ) where
self.events_mut().register(id, event, f); Self::State: 'static,
}
fn register_controller<W: ?Sized, C: Controller<Self>>(
&mut self,
id: WeakWidget<W>,
controller: C,
) {
self.events_mut().controllers.register(id.id(), controller);
}
fn with_controller<C: Controller<Self>, T>(
&mut self,
id: ControllerId,
f: impl FnOnce(&mut C, &mut Self) -> T,
) -> Option<T> {
let mut controller = self.events_mut().controllers.take::<C>(id)?;
let result = f(&mut controller, self);
self.events_mut().controllers.put(id, controller);
Some(result)
}
fn with_nearest_controller<C: Controller<Self>, T>(
&mut self,
origin: impl IdLike,
f: impl FnOnce(ControllerId, &mut C, &mut Self) -> T,
) -> Option<T> {
let render_handle = self.ui().render_state();
let id = self
.events()
.controllers
.nearest_id::<C>(origin.id(), &render_handle.get())?;
self.with_controller(id, |controller, rsc| f(id, controller, rsc))
}
fn set_command_target(&mut self, target: Option<ControllerId>) {
self.events_mut().controllers.set_command_target(target);
}
fn run_command(&mut self, command: Command) -> CommandResult {
let revision = self.events().controllers.command_target_revision();
if let Some(boundary) = self.events().controllers.command_boundary() {
let render_handle = self.ui().render_state();
let outside_boundary =
self.events()
.controllers
.command_target()
.is_none_or(|target| {
!self.events().controllers.is_below(
target.host(),
boundary,
&render_handle.get(),
)
});
if outside_boundary {
return CommandResult::Unused;
}
}
let Some((id, mut controller)) = self.events_mut().controllers.take_command_target() else {
return CommandResult::Unused;
};
let result = controller.command(command, self);
self.events_mut().controllers.restore(id, controller);
if command == Command::Escape
&& result != CommandResult::Unused
&& self.events().controllers.command_target_revision() == revision
{ {
self.set_command_target(None); self.events_mut().register(id, event, f);
}
result
}
#[doc(hidden)]
fn run_command_before(&mut self, command: Command, boundary: impl IdLike) -> CommandResult {
let old = self.events().controllers.command_boundary();
self.events_mut()
.controllers
.set_command_boundary(Some(boundary.id()));
let result = self.run_command(command);
self.events_mut().controllers.set_command_boundary(old);
result
} }
} }
pub trait RunEvents: HasEvents { pub trait RunEvents: HasEvents + HasState<State = Self> + 'static {
fn run_event<E: EventLike>( fn run_event<E: EventLike>(
&mut self, &mut self,
id: impl IdLike, id: impl IdLike,
data: <E::Event as Event>::Data<'_>, data: &mut <E::Event as Event>::Data<'_>,
state: &mut Self::State,
) { ) {
// Keep the last completed frame read-locked for the whole callback.
// Rsc methods may take further shared reads through `render_state`,
// while any attempt to start a render from an event fails at the
// mutable-borrow boundary instead of exposing an in-progress tree.
let render_handle = self.ui().render_state();
let _render_state = render_handle.get();
let f = self.events_mut().get_type::<E>().run_fn(id); let f = self.events_mut().get_type::<E>().run_fn(id);
f(EventCtx { state, data }, self) f(EventCtx { state: self, data })
} }
} }
impl<T: HasEvents> RunEvents for T {} impl<T: HasEvents + HasState<State = Self> + 'static> RunEvents for T {}
+5
View File
@@ -2,9 +2,12 @@
#![feature(const_ops)] #![feature(const_ops)]
#![feature(const_trait_impl)] #![feature(const_trait_impl)]
#![feature(const_convert)] #![feature(const_convert)]
#![feature(map_try_insert)]
#![feature(unboxed_closures)] #![feature(unboxed_closures)]
#![feature(fn_traits)] #![feature(fn_traits)]
#![feature(const_cmp)]
#![feature(const_destruct)] #![feature(const_destruct)]
#![feature(portable_simd)]
#![feature(associated_type_defaults)] #![feature(associated_type_defaults)]
#![feature(unsize)] #![feature(unsize)]
#![feature(coerce_unsized)] #![feature(coerce_unsized)]
@@ -29,3 +32,5 @@ pub use primitive::*;
pub use render::*; pub use render::*;
pub use ui::*; pub use ui::*;
pub use widget::*; pub use widget::*;
pub type UiColor = primitive::Color<u8>;
+5 -5
View File
@@ -5,19 +5,19 @@ pub const trait UiNum {
fn to_f32(self) -> f32; fn to_f32(self) -> f32;
} }
const impl UiNum for f32 { impl const UiNum for f32 {
fn to_f32(self) -> f32 { fn to_f32(self) -> f32 {
self self
} }
} }
const impl UiNum for u32 { impl const UiNum for u32 {
fn to_f32(self) -> f32 { fn to_f32(self) -> f32 {
self as f32 self as f32
} }
} }
const impl UiNum for i32 { impl const UiNum for i32 {
fn to_f32(self) -> f32 { fn to_f32(self) -> f32 {
self as f32 self as f32
} }
@@ -27,7 +27,7 @@ pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 {
Vec2::new(x.to_f32(), y.to_f32()) Vec2::new(x.to_f32(), y.to_f32())
} }
const impl<T: const UiNum + Copy> From<T> for Vec2 { impl<T: const UiNum + Copy> const From<T> for Vec2 {
fn from(v: T) -> Self { fn from(v: T) -> Self {
Self { Self {
x: v.to_f32(), x: v.to_f32(),
@@ -36,7 +36,7 @@ const impl<T: const UiNum + Copy> From<T> for Vec2 {
} }
} }
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for Vec2 impl<T: const UiNum, U: const UiNum> const From<(T, U)> for Vec2
where where
(T, U): const Destruct, (T, U): const Destruct,
{ {
+23 -7
View File
@@ -2,7 +2,7 @@ use crate::vec2;
use super::*; use super::*;
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Align { pub struct Align {
pub x: Option<AxisAlign>, pub x: Option<AxisAlign>,
pub y: Option<AxisAlign>, pub y: Option<AxisAlign>,
@@ -24,13 +24,26 @@ impl Align {
pub const TOP: CardinalAlign = CardinalAlign::TOP; pub const TOP: CardinalAlign = CardinalAlign::TOP;
pub const V_CENTER: CardinalAlign = CardinalAlign::V_CENTER; pub const V_CENTER: CardinalAlign = CardinalAlign::V_CENTER;
pub const BOT: CardinalAlign = CardinalAlign::BOT; pub const BOT: CardinalAlign = CardinalAlign::BOT;
pub const NONE: Align = Align { x: None, y: None };
pub fn tuple(&self) -> (Option<AxisAlign>, Option<AxisAlign>) { pub fn tuple(&self) -> (Option<AxisAlign>, Option<AxisAlign>) {
(self.x, self.y) (self.x, self.y)
} }
/// naming is a bit inconsistent w option,
/// normally would return Self, but can't see
/// that being needed atm and would wanna do
/// a trait if so, so they can both be named .or
/// (because Self impls From<RegionAlign>)
pub fn or(&self, other: RegionAlign) -> RegionAlign {
RegionAlign {
x: self.x.unwrap_or(other.x),
y: self.y.unwrap_or(other.x),
}
}
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisAlign { pub enum AxisAlign {
Neg, Neg,
Center, Center,
@@ -88,10 +101,6 @@ impl RegionAlign {
pub const fn rel(&self) -> Vec2 { pub const fn rel(&self) -> Vec2 {
vec2(self.x.rel(), self.y.rel()) vec2(self.x.rel(), self.y.rel())
} }
pub const fn pos(self) -> UiVec2 {
UiVec2::from(self)
}
} }
impl UiVec2 { impl UiVec2 {
@@ -110,6 +119,7 @@ impl UiVec2 {
} }
} }
/// aligns this as a size within a region
pub fn align(&self, align: RegionAlign) -> UiRegion { pub fn align(&self, align: RegionAlign) -> UiRegion {
UiRegion { UiRegion {
x: self.x.align(align.x), x: self.x.align(align.x),
@@ -191,8 +201,14 @@ impl From<CardinalAlign> for Align {
} }
} }
const impl From<RegionAlign> for UiVec2 { impl const From<RegionAlign> for UiVec2 {
fn from(align: RegionAlign) -> Self { fn from(align: RegionAlign) -> Self {
Self::rel(align.rel()) Self::rel(align.rel())
} }
} }
impl RegionAlign {
pub const fn pos(self) -> UiVec2 {
UiVec2::from(self)
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
use super::*; use super::*;
#[derive(Copy, Clone, Eq, PartialEq, Debug)] #[derive(Copy, Clone, Eq, PartialEq)]
pub enum Axis { pub enum Axis {
X, X,
Y, Y,
@@ -74,14 +74,14 @@ pub const trait AxisT {
} }
pub struct XAxis; pub struct XAxis;
const impl AxisT for XAxis { impl const AxisT for XAxis {
fn get() -> Axis { fn get() -> Axis {
Axis::X Axis::X
} }
} }
pub struct YAxis; pub struct YAxis;
const impl AxisT for YAxis { impl const AxisT for YAxis {
fn get() -> Axis { fn get() -> Axis {
Axis::Y Axis::Y
} }
+38 -244
View File
@@ -3,31 +3,13 @@ use crate::{UiNum, util::impl_op};
#[derive(Debug, Default, Clone, Copy, PartialEq)] #[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct Size { pub struct Size {
pub x: LayoutLen, pub x: Len,
pub y: LayoutLen, pub y: Len,
} }
/// A length resolved from physical pixels, density-independent pixels, and a
/// fraction of a reference length. Unlike [`LayoutLen`], it carries no claim
/// on space left over by a layout container.
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct Len { pub struct Len {
/// Physical pixels -- a raw device pixel, unaffected by the display's
/// density. Rare to want directly (a hairline border is the usual
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
/// the two are kept separate rather than one field a caller has to
/// remember to pre-multiply.
pub abs: f32, pub abs: f32,
pub dp: f32,
pub rel: f32,
}
/// A widget length plus its proportional claim on the space left after fixed
/// and relative lengths have been allocated.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayoutLen {
pub abs: f32,
pub dp: f32,
pub rel: f32, pub rel: f32,
pub rest: f32, pub rest: f32,
} }
@@ -38,25 +20,8 @@ impl<N: UiNum> From<N> for Len {
} }
} }
impl<N: UiNum> From<N> for LayoutLen { impl<Nx: UiNum, Ny: UiNum> From<(Nx, Ny)> for Size {
fn from(value: N) -> Self { fn from((x, y): (Nx, Ny)) -> Self {
Self::abs(value.to_f32())
}
}
impl From<Len> for LayoutLen {
fn from(value: Len) -> Self {
Self {
abs: value.abs,
dp: value.dp,
rel: value.rel,
rest: 0.0,
}
}
}
impl<X: Into<LayoutLen>, Y: Into<LayoutLen>> From<(X, Y)> for Size {
fn from((x, y): (X, Y)) -> Self {
Self { Self {
x: x.into(), x: x.into(),
y: y.into(), y: y.into(),
@@ -64,58 +29,52 @@ impl<X: Into<LayoutLen>, Y: Into<LayoutLen>> From<(X, Y)> for Size {
} }
} }
impl From<LayoutLen> for Size {
fn from(value: LayoutLen) -> Self {
Self { x: value, y: value }
}
}
impl From<Len> for Size { impl From<Len> for Size {
fn from(value: Len) -> Self { fn from(value: Len) -> Self {
Self::from(LayoutLen::from(value)) Self { x: value, y: value }
} }
} }
impl Size { impl Size {
pub const ZERO: Self = Self { pub const ZERO: Self = Self {
x: LayoutLen::ZERO, x: Len::ZERO,
y: LayoutLen::ZERO, y: Len::ZERO,
}; };
pub const REST: Self = Self { pub const REST: Self = Self {
x: LayoutLen::REST, x: Len::REST,
y: LayoutLen::REST, y: Len::REST,
}; };
pub fn abs(v: Vec2) -> Self { pub fn abs(v: Vec2) -> Self {
Self { Self {
x: LayoutLen::abs(v.x), x: Len::abs(v.x),
y: LayoutLen::abs(v.y), y: Len::abs(v.y),
} }
} }
pub fn rel(v: Vec2) -> Self { pub fn rel(v: Vec2) -> Self {
Self { Self {
x: LayoutLen::rel(v.x), x: Len::rel(v.x),
y: LayoutLen::rel(v.y), y: Len::rel(v.y),
} }
} }
pub fn rest(v: Vec2) -> Self { pub fn rest(v: Vec2) -> Self {
Self { Self {
x: LayoutLen::rest(v.x), x: Len::rest(v.x),
y: LayoutLen::rest(v.y), y: Len::rest(v.y),
} }
} }
pub fn to_uivec2(self, density: f32) -> UiVec2 { pub fn to_uivec2(self) -> UiVec2 {
UiVec2 { UiVec2 {
x: self.x.apply_rest(density), x: self.x.apply_rest(),
y: self.y.apply_rest(density), y: self.y.apply_rest(),
} }
} }
pub fn from_axis(axis: Axis, aligned: LayoutLen, ortho: LayoutLen) -> Self { pub fn from_axis(axis: Axis, aligned: Len, ortho: Len) -> Self {
match axis { match axis {
Axis::X => Self { Axis::X => Self {
x: aligned, x: aligned,
@@ -128,7 +87,7 @@ impl Size {
} }
} }
pub fn axis(&self, axis: Axis) -> LayoutLen { pub fn axis(&self, axis: Axis) -> Len {
match axis { match axis {
Axis::X => self.x, Axis::X => self.x,
Axis::Y => self.y, Axis::Y => self.y,
@@ -136,69 +95,29 @@ impl Size {
} }
} }
impl LayoutLen { impl Len {
pub const ZERO: Self = Self { pub const ZERO: Self = Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
}; };
pub const REST: Self = Self { pub const REST: Self = Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: 1.0, rest: 1.0,
}; };
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against pub fn apply_rest(&self) -> UiScalar {
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
/// unscaled display, `content_scale` on Android; see `dp`'s field
/// doc). Every other component of `LayoutLen` is already resolution-
/// independent (`rel` is a fraction of the parent; `rest` becomes a
/// fraction too, below), so `density` only ever touches this one term.
pub fn apply_rest(&self, density: f32) -> UiScalar {
UiScalar { UiScalar {
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 }, rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
abs: self.abs + self.dp * density, abs: self.abs,
}
}
/// The same fold as [`Self::apply_rest`] but staying a `LayoutLen`, so
/// `rest` survives: `dp` becomes physical pixels and every other
/// component is left alone.
///
/// **A `LayoutLen` a widget *reports* must have been through this.** `dp` is
/// an input unit -- a number the widget author wrote -- and the
/// containers that consume a reported length read `abs`/`rel`/`rest`
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
/// so a reported `dp` is silently worth zero. That is what made the
/// composer's bar collapse to nothing the moment its content grew past
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
/// so the bar was given a slot of 0 and the field inside it was panned
/// out of a container measured at -63px. `UiRenderState::draw_inner`
/// debug-asserts the invariant after every `Widget::draw`.
pub fn fold_dp(&self, density: f32) -> Self {
Self {
abs: self.abs + self.dp * density,
dp: 0.0,
rel: self.rel,
rest: self.rest,
} }
} }
pub fn abs(abs: impl UiNum) -> Self { pub fn abs(abs: impl UiNum) -> Self {
Self { Self {
abs: abs.to_f32(), abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
} }
@@ -206,7 +125,6 @@ impl LayoutLen {
pub fn rel(rel: impl UiNum) -> Self { pub fn rel(rel: impl UiNum) -> Self {
Self { Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: rel.to_f32(), rel: rel.to_f32(),
rest: 0.0, rest: 0.0,
} }
@@ -214,116 +132,47 @@ impl LayoutLen {
pub fn rest(ratio: impl UiNum) -> Self { pub fn rest(ratio: impl UiNum) -> Self {
Self { Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: ratio.to_f32(), rest: ratio.to_f32(),
} }
} }
} }
impl Len {
pub const ZERO: Self = Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
};
pub fn abs(abs: impl UiNum) -> Self {
Self {
abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0,
}
}
pub fn rel(rel: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: 0.0,
rel: rel.to_f32(),
}
}
pub const fn fold_dp(self, density: f32) -> Self {
Self {
abs: self.abs + self.dp * density,
dp: 0.0,
rel: self.rel,
}
}
pub const fn resolve(self, density: f32) -> UiScalar {
let folded = self.fold_dp(density);
UiScalar {
rel: folded.rel,
abs: folded.abs,
}
}
}
pub mod len_fns { pub mod len_fns {
use super::*; use super::*;
pub fn abs(abs: impl UiNum) -> Len { pub fn abs(abs: impl UiNum) -> Len {
Len::abs(abs) Len {
abs: abs.to_f32(),
rel: 0.0,
rest: 0.0,
} }
pub fn dp(dp: impl UiNum) -> Len {
Len::dp(dp)
} }
pub fn rel(rel: impl UiNum) -> Len { pub fn rel(rel: impl UiNum) -> Len {
Len::rel(rel) Len {
abs: 0.0,
rel: rel.to_f32(),
rest: 0.0,
} }
pub fn rest(ratio: impl UiNum) -> LayoutLen { }
LayoutLen { pub fn rest(ratio: impl UiNum) -> Len {
Len {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: ratio.to_f32(), rest: ratio.to_f32(),
} }
} }
} }
impl_op!(LayoutLen Add add; abs dp rel rest); impl_op!(Len Add add; abs rel rest);
impl_op!(LayoutLen Sub sub; abs dp rel rest); impl_op!(Len Sub sub; abs rel rest);
impl_op!(Len Add add; abs dp rel);
impl_op!(Len Sub sub; abs dp rel);
impl std::ops::Add<Len> for LayoutLen {
type Output = Self;
fn add(self, rhs: Len) -> Self::Output {
self + Self::from(rhs)
}
}
impl std::ops::Sub<Len> for LayoutLen {
type Output = Self;
fn sub(self, rhs: Len) -> Self::Output {
self - Self::from(rhs)
}
}
impl_op!(Size Add add; x y); impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y); impl_op!(Size Sub sub; x y);
impl Default for LayoutLen {
fn default() -> Self {
Self::rest(1.0)
}
}
impl Default for Len { impl Default for Len {
fn default() -> Self { fn default() -> Self {
Self::ZERO Self::rest(1.0)
} }
} }
@@ -333,14 +182,11 @@ impl std::fmt::Display for Size {
} }
} }
impl std::fmt::Display for LayoutLen { impl std::fmt::Display for Len {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.abs != 0.0 { if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?; write!(f, "{} abs;", self.abs)?;
} }
if self.dp != 0.0 {
write!(f, "{} dp;", self.dp)?;
}
if self.rel != 0.0 { if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?; write!(f, "{} rel;", self.rel)?;
} }
@@ -350,55 +196,3 @@ impl std::fmt::Display for LayoutLen {
Ok(()) Ok(())
} }
} }
impl std::fmt::Display for Len {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?;
}
if self.dp != 0.0 {
write!(f, "{} dp;", self.dp)?;
}
if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_ordinary_length_enters_layout_without_claiming_rest() {
let layout = LayoutLen::from(Len {
abs: 3.0,
dp: 4.0,
rel: 0.5,
});
assert_eq!(layout.abs, 3.0);
assert_eq!(layout.dp, 4.0);
assert_eq!(layout.rel, 0.5);
assert_eq!(layout.rest, 0.0);
}
#[test]
fn ordinary_and_layout_lengths_keep_their_own_defaults() {
assert_eq!(Len::default(), Len::ZERO);
assert_eq!(LayoutLen::default(), LayoutLen::REST);
}
#[test]
fn adding_an_ordinary_length_preserves_a_layout_claim() {
assert_eq!(
LayoutLen::rest(2) + Len::dp(8),
LayoutLen {
abs: 0.0,
dp: 8.0,
rel: 0.0,
rest: 2.0,
}
);
}
}
+14 -4
View File
@@ -124,13 +124,13 @@ impl Display for UiVec2 {
impl_op!(UiVec2 Add add; x y); impl_op!(UiVec2 Add add; x y);
impl_op!(UiVec2 Sub sub; x y); impl_op!(UiVec2 Sub sub; x y);
const impl From<Vec2> for UiVec2 { impl const From<Vec2> for UiVec2 {
fn from(abs: Vec2) -> Self { fn from(abs: Vec2) -> Self {
Self::abs(abs) Self::abs(abs)
} }
} }
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for UiVec2 impl<T: const UiNum, U: const UiNum> const From<(T, U)> for UiVec2
where where
(T, U): const Destruct, (T, U): const Destruct,
{ {
@@ -140,12 +140,22 @@ where
} }
#[repr(C)] #[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, Default, bytemuck::Zeroable)] #[derive(Debug, Copy, Clone, bytemuck::Pod, Default, bytemuck::Zeroable)]
pub struct UiScalar { pub struct UiScalar {
pub rel: f32, pub rel: f32,
pub abs: f32, pub abs: f32,
} }
// TODO: unknown exactly what these should be
const REL_EPSILON: f32 = 0.00001;
const ABS_EPSILON: f32 = 0.1;
impl PartialEq for UiScalar {
fn eq(&self, other: &Self) -> bool {
(self.rel - other.rel).abs() < REL_EPSILON && (self.abs - other.abs).abs() < ABS_EPSILON
}
}
impl Eq for UiScalar {} impl Eq for UiScalar {}
impl Hash for UiScalar { impl Hash for UiScalar {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
@@ -421,7 +431,7 @@ impl Display for UiRegion {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug)]
pub struct PixelRegion { pub struct PixelRegion {
pub top_left: Vec2, pub top_left: Vec2,
pub bot_right: Vec2, pub bot_right: Vec2,
+129 -454
View File
@@ -1,488 +1,163 @@
use crate::util::{Dirty, Resources, StrongRscId}; use std::marker::Destruct;
use std::{cell::RefCell, fmt, rc::Rc};
/// Encoded, straight-alpha sRGB at an input boundary. /// stored in linear for sane manipulation
///
/// Palette literals, decoded images and colour glyph bitmaps use this
/// convention. A solid paint is converted to linear light when it enters the
/// paint table; the renderer never performs colour arithmetic on these bytes.
#[repr(C)] #[repr(C)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
pub struct Srgba8 { pub struct Color<T> {
pub r: u8, pub r: T,
pub g: u8, pub g: T,
pub b: u8, pub b: T,
pub a: u8, pub a: T,
} }
impl Srgba8 { impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(0, 0, 0); pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(255, 255, 255); pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
pub const GRAY: Self = Self::rgb(127, 127, 127); pub const GRAY: Self = Self::rgb(T::MID, T::MID, T::MID);
pub const RED: Self = Self::rgb(255, 0, 0);
pub const ORANGE: Self = Self::rgb(255, 127, 0);
pub const YELLOW: Self = Self::rgb(255, 255, 0);
pub const LIME: Self = Self::rgb(127, 255, 0);
pub const GREEN: Self = Self::rgb(0, 255, 0);
pub const TURQUOISE: Self = Self::rgb(0, 255, 127);
pub const CYAN: Self = Self::rgb(0, 255, 255);
pub const SKY: Self = Self::rgb(0, 127, 255);
pub const BLUE: Self = Self::rgb(0, 0, 255);
pub const PURPLE: Self = Self::rgb(127, 0, 255);
pub const MAGENTA: Self = Self::rgb(255, 0, 255);
pub const NONE: Self = Self::new(0, 0, 0, 0);
pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self { pub const RED: Self = Self::rgb(T::MAX, T::MIN, T::MIN);
pub const ORANGE: Self = Self::rgb(T::MAX, T::MID, T::MIN);
pub const YELLOW: Self = Self::rgb(T::MAX, T::MAX, T::MIN);
pub const LIME: Self = Self::rgb(T::MID, T::MAX, T::MIN);
pub const GREEN: Self = Self::rgb(T::MIN, T::MAX, T::MIN);
pub const TURQUOISE: Self = Self::rgb(T::MIN, T::MAX, T::MID);
pub const CYAN: Self = Self::rgb(T::MIN, T::MAX, T::MAX);
pub const SKY: Self = Self::rgb(T::MIN, T::MID, T::MAX);
pub const BLUE: Self = Self::rgb(T::MIN, T::MIN, T::MAX);
pub const PURPLE: Self = Self::rgb(T::MID, T::MIN, T::MAX);
pub const MAGENTA: Self = Self::rgb(T::MAX, T::MIN, T::MAX);
pub const NONE: Self = Self::new(T::MIN, T::MIN, T::MIN, T::MIN);
pub const fn new(r: T, g: T, b: T, a: T) -> Self {
Self { r, g, b, a } Self { r, g, b, a }
} }
pub const fn rgb(r: T, g: T, b: T) -> Self {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self { Self { r, g, b, a: T::MAX }
Self::new(r, g, b, 255) }
pub fn alpha(mut self, a: T) -> Self {
self.a = a;
self
} }
pub fn to_linear(self) -> LinearRgba { pub fn as_arr(self) -> [T; 4] {
LinearRgba::new( [self.r, self.g, self.b, self.a]
srgb_to_linear(self.r as f32 / 255.0),
srgb_to_linear(self.g as f32 / 255.0),
srgb_to_linear(self.b as f32 / 255.0),
self.a as f32 / 255.0,
)
} }
} }
/// Straight-alpha RGBA in linear-light sRGB primaries. pub const trait F32Conversion {
/// fn to(self) -> f32;
/// This is Iris's working representation: manipulate and interpolate colours fn from(x: f32) -> Self;
/// here, then put the result in [`Paints`]. The GPU paint buffer stores this
/// exact layout as `vec4<f32>`.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LinearRgba {
pub r: f32,
pub g: f32,
pub b: f32,
pub a: f32,
} }
impl LinearRgba { pub trait ColorNum {
pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0); const MIN: Self;
pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0); const MID: Self;
pub const NONE: Self = Self::new(0.0, 0.0, 0.0, 0.0); const MAX: Self;
pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self {
Self { r, g, b, a }
}
pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
Self::new(r, g, b, 1.0)
}
pub fn mul_rgb(self, amount: f32) -> Self {
Self::new(self.r * amount, self.g * amount, self.b * amount, self.a)
}
pub fn darker(self, amount: f32) -> Self {
self.mul_rgb(1.0 - amount)
}
pub fn brighter(self, amount: f32) -> Self {
Self::new(
self.r + (1.0 - self.r) * amount,
self.g + (1.0 - self.g) * amount,
self.b + (1.0 - self.b) * amount,
self.a,
)
}
pub fn to_wgpu(self) -> wgpu::Color {
wgpu::Color {
r: self.r as f64,
g: self.g as f64,
b: self.b as f64,
a: self.a as f64,
}
}
} }
fn srgb_to_linear(value: f32) -> f32 { macro_rules! map_rgb {
if value <= 0.04045 { ($x:ident,$self:ident, $e:tt) => {
value / 12.92 #[allow(unused_braces)]
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
/// A description that can be registered in Iris's paint table.
///
/// Only solid paints exist today. Keeping registration behind this trait and
/// making primitives carry [`PaintId`] leaves one place to add gradient or
/// texture paint records later.
pub trait Paint: private::Sealed + 'static {
#[doc(hidden)]
fn add_to(&self, paints: &mut Paints) -> PaintId;
#[doc(hidden)]
fn replace(&self, paints: &mut Paints, slot: u32);
/// Erases this definition so a widget can register it lazily on its
/// first draw. [`PaintId`] overrides this to stay a direct handle.
#[doc(hidden)]
fn into_value(self) -> PaintValue
where
Self: Sized,
{
PaintValue::pending(self)
}
}
impl Paint for Srgba8 {
fn add_to(&self, paints: &mut Paints) -> PaintId {
paints.add_linear(self.to_linear())
}
fn replace(&self, paints: &mut Paints, slot: u32) {
paints.replace_linear(slot, self.to_linear());
}
}
impl Paint for LinearRgba {
fn add_to(&self, paints: &mut Paints) -> PaintId {
paints.add_linear(*self)
}
fn replace(&self, paints: &mut Paints, slot: u32) {
paints.replace_linear(slot, *self);
}
}
mod private {
pub trait Sealed {}
impl Sealed for super::Srgba8 {}
impl Sealed for super::LinearRgba {}
impl Sealed for super::PaintId {}
}
struct PaintRsc;
/// A stable reference to one entry in a UI's paint table.
///
/// Built-in IDs name the same reserved entries in every [`Paints`]. IDs
/// returned by [`Paints::add`] retain their slot until the last clone held by a
/// widget, shaped text or retained draw is dropped.
#[derive(Clone, Debug)]
pub struct PaintId {
slot: u32,
strong: Option<StrongRscId<PaintRsc>>,
}
impl PaintId {
pub const BLACK: Self = Self::builtin(0);
pub const WHITE: Self = Self::builtin(1);
pub const GRAY: Self = Self::builtin(2);
pub const RED: Self = Self::builtin(3);
pub const ORANGE: Self = Self::builtin(4);
pub const YELLOW: Self = Self::builtin(5);
pub const LIME: Self = Self::builtin(6);
pub const GREEN: Self = Self::builtin(7);
pub const TURQUOISE: Self = Self::builtin(8);
pub const CYAN: Self = Self::builtin(9);
pub const SKY: Self = Self::builtin(10);
pub const BLUE: Self = Self::builtin(11);
pub const PURPLE: Self = Self::builtin(12);
pub const MAGENTA: Self = Self::builtin(13);
pub const NONE: Self = Self::builtin(14);
const fn builtin(slot: u32) -> Self {
Self { slot, strong: None }
}
pub(crate) fn slot(&self) -> u32 {
self.slot
}
fn is_managed(&self) -> bool {
self.strong.is_some()
}
}
impl Default for PaintId {
fn default() -> Self {
Self::BLACK
}
}
impl PartialEq for PaintId {
fn eq(&self, other: &Self) -> bool {
self.slot == other.slot
}
}
impl Eq for PaintId {}
impl Paint for PaintId {
fn add_to(&self, _paints: &mut Paints) -> PaintId {
self.clone()
}
fn replace(&self, paints: &mut Paints, slot: u32) {
let value = paints.entries[self.slot as usize];
paints.replace_linear(slot, value);
}
fn into_value(self) -> PaintValue {
PaintValue(PaintValueInner::Id(self))
}
}
struct PendingPaint {
definition: Box<dyn Paint>,
resolved: RefCell<Option<PaintId>>,
}
#[derive(Clone)]
enum PaintValueInner {
Id(PaintId),
Pending(Rc<PendingPaint>),
}
/// A widget property containing either an existing paint-table ID or a paint
/// definition that will receive an ID the first time it is drawn.
///
/// Pending definitions are shared across clones and registered only once.
/// Each property replaces its own pending variant with the resulting direct
/// ID after that first resolution, so later draws take the direct path.
#[derive(Clone)]
pub struct PaintValue(PaintValueInner);
impl PaintValue {
fn pending(paint: impl Paint) -> Self {
Self(PaintValueInner::Pending(Rc::new(PendingPaint {
definition: Box::new(paint),
resolved: RefCell::new(None),
})))
}
pub fn resolve(&mut self, paints: &mut Paints) -> &PaintId {
if let PaintValueInner::Pending(pending) = &self.0 {
let resolved = pending.resolved.borrow().clone();
let id = match resolved {
Some(id) => id,
None => {
let id = pending.definition.add_to(paints);
*pending.resolved.borrow_mut() = Some(id.clone());
id
}
};
self.0 = PaintValueInner::Id(id);
}
let PaintValueInner::Id(id) = &self.0 else {
unreachable!()
};
id
}
pub fn is(&self, id: &PaintId) -> bool {
match &self.0 {
PaintValueInner::Id(current) => current == id,
PaintValueInner::Pending(pending) => pending.resolved.borrow().as_ref() == Some(id),
}
}
}
impl fmt::Debug for PaintValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
PaintValueInner::Id(id) => f.debug_tuple("PaintValue").field(id).finish(),
PaintValueInner::Pending(_) => f.write_str("PaintValue(Pending)"),
}
}
}
const BUILTIN_PAINTS: [Srgba8; 15] = [
Srgba8::BLACK,
Srgba8::WHITE,
Srgba8::GRAY,
Srgba8::RED,
Srgba8::ORANGE,
Srgba8::YELLOW,
Srgba8::LIME,
Srgba8::GREEN,
Srgba8::TURQUOISE,
Srgba8::CYAN,
Srgba8::SKY,
Srgba8::BLUE,
Srgba8::PURPLE,
Srgba8::MAGENTA,
Srgba8::NONE,
];
/// CPU-side paint table and the dirty set for its GPU mirror.
pub struct Paints {
resources: Resources<PaintRsc>,
entries: Vec<LinearRgba>,
dirty: Dirty,
}
impl Paints {
pub fn new() -> Self {
let mut resources = Resources::new();
for (slot, _) in BUILTIN_PAINTS.iter().enumerate() {
let id = resources.add_static(PaintRsc);
assert_eq!(id.slot(), slot as u32);
}
Self { Self {
resources, r: {
entries: BUILTIN_PAINTS.map(Srgba8::to_linear).to_vec(), let $x = $self.r;
dirty: Dirty::new_all(), $e
},
g: {
let $x = $self.g;
$e
},
b: {
let $x = $self.b;
$e
},
a: $self.a,
}
};
}
impl<T: ColorNum + const F32Conversion> Color<T>
where
Self: const Destruct,
{
pub const fn mul_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() * amt) })
}
pub const fn add_rgb(self, amt: impl const F32Conversion) -> Self {
let amt = amt.to();
map_rgb!(x, self, { T::from(x.to() + amt) })
}
pub const fn darker(self, amt: f32) -> Self {
self.mul_rgb(1.0 - amt)
}
pub const fn brighter(self, amt: f32) -> Self {
map_rgb!(x, self, {
let x = x.to();
T::from(x + (1.0 - x) * amt)
})
}
pub fn map_rgb(self, f: impl Fn(T) -> T) -> Self {
Self {
r: f(self.r),
g: f(self.g),
b: f(self.b),
a: self.a,
} }
} }
pub fn add(&mut self, paint: impl Paint) -> PaintId { pub fn srgb(r: T, g: T, b: T) -> Self {
paint.add_to(self) Self {
r: s_to_l(r),
g: s_to_l(g),
b: s_to_l(b),
a: T::MAX,
} }
}
}
fn add_linear(&mut self, value: LinearRgba) -> PaintId { fn s_to_l<T: F32Conversion>(x: T) -> T {
self.free_released(); let x = x.to();
let old_capacity = self.resources.capacity(); T::from(if x <= 0.0405 {
let strong = self.resources.add(PaintRsc); x / 12.92
let slot = strong.slot();
if (slot as usize) < old_capacity {
self.entries[slot as usize] = value;
self.dirty.mark(slot as usize);
} else { } else {
self.entries.push(value); ((x + 0.055) / 1.055).powf(2.4)
self.dirty.mark(slot as usize); })
} }
PaintId {
slot,
strong: Some(strong),
}
}
/// Replaces one managed paint in place. Every primitive keeps the same impl ColorNum for u8 {
/// index, so a theme change dirties this table and no primitive buffer. const MIN: Self = u8::MIN;
pub fn set(&mut self, id: &PaintId, paint: impl Paint) { const MID: Self = u8::MAX / 2;
assert!( const MAX: Self = u8::MAX;
id.is_managed(), }
"a reserved built-in paint cannot be replaced; allocate a theme slot with Paints::add"
);
paint.replace(self, id.slot);
}
fn replace_linear(&mut self, slot: u32, value: LinearRgba) { impl ColorNum for f32 {
self.entries[slot as usize] = value; const MIN: Self = 0.0;
self.dirty.mark(slot as usize); const MID: Self = 0.5;
} const MAX: Self = 1.0;
}
pub fn get(&self, id: &PaintId) -> LinearRgba { unsafe impl bytemuck::Pod for Color<u8> {}
self.entries[id.slot as usize]
}
pub fn free_released(&mut self) { impl const F32Conversion for f32 {
let entries = &mut self.entries; fn to(self) -> f32 {
let dirty = &mut self.dirty; self
self.resources.apply(|id, _| {
let slot = id.slot();
entries[slot as usize] = LinearRgba::NONE;
dirty.mark(slot as usize);
});
} }
fn from(x: f32) -> Self {
/// A new GPU device has no copy of this table even when the CPU-side UI x
/// and its paint IDs survived an Android surface recreation.
pub fn reupload(&mut self) {
self.dirty.mark_all();
}
pub fn for_upload(&mut self) -> (&[LinearRgba], &mut Dirty) {
(&self.entries, &mut self.dirty)
} }
} }
impl Default for Paints { impl const F32Conversion for u8 {
fn default() -> Self { fn to(self) -> f32 {
Self::new() self as f32 / 255.0
} }
} fn from(x: f32) -> Self {
(x * 255.0).clamp(0.0, 255.0) as Self
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn srgb_bytes_become_linear_without_transforming_alpha() {
let got = Srgba8::new(17, 127, 255, 64).to_linear();
assert!((got.r - 0.005605).abs() < 0.000001);
assert!((got.g - 0.212231).abs() < 0.000001);
assert_eq!(got.b, 1.0);
assert!((got.a - 64.0 / 255.0).abs() < f32::EPSILON);
}
#[test]
fn changing_a_paint_keeps_its_id_and_dirties_only_its_slot() {
let mut paints = Paints::new();
let id = paints.add(Srgba8::rgb(17, 17, 27));
let slot = id.slot();
let (_, dirty) = paints.for_upload();
dirty.clear();
paints.set(&id, Srgba8::rgb(205, 214, 244));
let (_, dirty) = paints.for_upload();
assert!(dirty.contains(slot as usize));
assert_eq!(id.slot(), slot);
}
#[test]
fn a_released_paint_slot_is_reused_only_after_the_last_clone() {
let mut paints = Paints::new();
let id = paints.add(Srgba8::RED);
let slot = id.slot();
let held = id.clone();
drop(id);
paints.free_released();
let other = paints.add(Srgba8::GREEN);
assert_ne!(other.slot(), slot);
drop(held);
paints.free_released();
let reused = paints.add(Srgba8::BLUE);
assert_eq!(reused.slot(), slot);
}
#[test]
fn cloned_pending_paints_register_once_and_then_become_direct_ids() {
let mut paints = Paints::new();
let mut first = Srgba8::rgb(17, 17, 27).into_value();
let mut second = first.clone();
let before = paints.entries.len();
let first_id = first.resolve(&mut paints).clone();
let second_id = second.resolve(&mut paints).clone();
assert_eq!(first_id, second_id);
assert_eq!(paints.entries.len(), before + 1);
assert!(first.is(&first_id));
assert!(second.is(&first_id));
}
#[test]
fn independently_constructed_inline_solids_get_independent_slots() {
let mut paints = Paints::new();
let mut first = Srgba8::rgb(23, 42, 71).into_value();
let mut second = Srgba8::rgb(23, 42, 71).into_value();
let before = paints.entries.len();
let first_id = first.resolve(&mut paints).clone();
let second_id = second.resolve(&mut paints).clone();
assert_ne!(first_id, second_id);
assert_eq!(paints.entries.len(), before + 2);
}
#[test]
fn explicit_theme_paints_with_equal_values_remain_independent() {
let mut paints = Paints::new();
let first = paints.add(Srgba8::rgb(23, 42, 71));
let second = paints.add(Srgba8::rgb(23, 42, 71));
assert_ne!(first.slot(), second.slot());
} }
} }
+25 -2
View File
@@ -1,6 +1,9 @@
use std::ops::{Index, IndexMut}; use std::ops::{Index, IndexMut};
use crate::{render::LayerOrder, util::to_mut}; use crate::{
render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
util::to_mut,
};
pub type LayerId = usize; pub type LayerId = usize;
@@ -14,13 +17,19 @@ struct LayerNode<T> {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
enum Ptr { enum Ptr {
/// continue on same level
Next(usize), Next(usize),
/// go back to parent
Parent(usize), Parent(usize),
/// end
None, None,
} }
/// TODO: currently this does not ever free layers
/// is that realistically desired?
pub struct Layers<T> { pub struct Layers<T> {
vec: Vec<LayerNode<T>>, vec: Vec<LayerNode<T>>,
/// index of last layer at top level (start at first = 0)
last: usize, last: usize,
} }
@@ -30,7 +39,7 @@ struct Child {
tail: usize, tail: usize,
} }
pub type PrimitiveLayers = Layers<LayerOrder>; pub type PrimitiveLayers = Layers<Primitives>;
impl<T: Default> Layers<T> { impl<T: Default> Layers<T> {
pub fn new() -> Layers<T> { pub fn new() -> Layers<T> {
@@ -110,6 +119,20 @@ impl<T: Default> Layers<T> {
} }
} }
impl PrimitiveLayers {
pub fn write<P: Primitive>(
&mut self,
layer: LayerId,
info: PrimitiveInst<P>,
) -> PrimitiveHandle {
self[layer].write(layer, info)
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self[h.layer].free(h)
}
}
impl<T: Default> Default for Layers<T> { impl<T: Default> Default for Layers<T> {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
+135 -1083
View File
File diff suppressed because it is too large. Load diff
+60 -301
View File
@@ -1,233 +1,103 @@
use crate::util::{Resources, RscHandle, StrongRscId, Vec2, WeakRscId}; use crate::{
render::TexturePrimitive,
util::{RefCounter, Vec2},
};
use image::{DynamicImage, GenericImageView}; use image::{DynamicImage, GenericImageView};
use std::{cell::RefCell, collections::HashMap, ops::Index, rc::Rc}; use std::{
ops::Index,
/// Which of the two things a texture slot holds. See TEXTURES.md's sync::mpsc::{Receiver, Sender, channel},
/// "Recommended shape" for why these are drawn so differently: a page is a };
/// layer of one shared array texture and never gets its own bind group; a
/// standalone image is the opposite, one texture and one bind group, never a
/// layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextureKind {
Image,
/// The array-texture layer this page was assigned. Chosen synchronously
/// by `Textures::add_page` rather than by the renderer, because glyph
/// insertion needs it in the same call, before any GPU sync happens.
Page {
layer: u32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SharedTextureKey {
pub owner: &'static str,
pub id: u64,
}
pub struct TextureRsc {
kind: TextureKind,
size: Vec2,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TextureHandle { pub struct TextureHandle {
rsc: RscHandle<TextureRsc>, inner: TexturePrimitive,
size: Vec2,
counter: RefCounter,
send: Sender<u32>,
} }
impl PartialEq for TextureHandle {
fn eq(&self, other: &Self) -> bool {
self.rsc.id() == other.rsc.id()
}
}
impl Eq for TextureHandle {}
/// a texture manager for a ui /// a texture manager for a ui
/// note that this is heavily oriented towards wgpu's renderer so the primitives don't need mapped /// note that this is heavily oriented towards wgpu's renderer so the primitives don't need mapped
pub struct Textures { pub struct Textures {
resources: Rc<RefCell<Resources<TextureRsc>>>, free: Vec<u32>,
images: Vec<Option<DynamicImage>>, images: Vec<Option<DynamicImage>>,
kinds: Vec<TextureKind>,
/// Textures built from a description rather than from a file, one per
/// distinct description: see [`Textures::shared`]. The map holds a
/// reference of its own, so a shared texture outlives every widget
/// drawing it and its slot is never recycled underneath one.
shared: HashMap<SharedTextureKey, TextureHandle>,
/// Next layer to hand out to an atlas page. Page layers and resource slots
/// are separate identities: released page layers are reused without moving
/// any still-live page.
next_page_layer: u32,
free_page_layers: Vec<u32>,
updates: Vec<Update>, updates: Vec<Update>,
send: Sender<u32>,
recv: Receiver<u32>,
} }
pub enum TextureUpdate<'a> { pub enum TextureUpdate<'a> {
Push(TextureKind, &'a DynamicImage), Push(&'a DynamicImage),
Set(TextureKind, u32, &'a DynamicImage), Set(u32, &'a DynamicImage),
/// Overwrite a rectangle of an existing texture, rather than replacing it.
/// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas
/// per glyph is megabytes of copy for a few hundred bytes of change.
/// Only ever issued against a page -- a standalone image is never patched.
Patch(u32, PatchRect, &'a DynamicImage),
Free(u32), Free(u32),
PushFree(TextureKind), PushFree,
SetFree, SetFree,
} }
#[derive(Debug, Clone, Copy)]
pub struct PatchRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
enum Update { enum Update {
Push(TextureKind, u32), Push(u32),
Set(TextureKind, u32), Set(u32),
Patch(u32, PatchRect),
Free(u32), Free(u32),
} }
impl Textures { impl Textures {
pub fn new() -> Self { pub fn new() -> Self {
let (send, recv) = channel();
Self { Self {
resources: Rc::new(RefCell::new(Resources::new())), free: Vec::new(),
images: Vec::new(), images: Vec::new(),
kinds: Vec::new(),
shared: HashMap::new(),
next_page_layer: 0,
free_page_layers: Vec::new(),
updates: Vec::new(), updates: Vec::new(),
send,
recv,
} }
} }
pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle { pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
let image = image.into(); let image = image.into();
let size = image.dimensions().into(); let size = image.dimensions().into();
let kind = TextureKind::Image; let view_idx = self.push(image);
self.push(kind, size, image) // 0 == default in renderer; TODO: actually create samplers here
} let sampler_idx = 0;
pub fn add_page(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
self.free();
let image = image.into();
let size = image.dimensions().into();
let layer = self.free_page_layers.pop().unwrap_or_else(|| {
let layer = self.next_page_layer;
self.next_page_layer += 1;
layer
});
let kind = TextureKind::Page { layer };
self.push(kind, size, image)
}
pub fn handle(&self, id: StrongRscId<TextureRsc>) -> TextureHandle {
TextureHandle { TextureHandle {
rsc: RscHandle::new(id, self.resources.clone()), inner: TexturePrimitive {
view_idx,
sampler_idx,
},
size,
counter: RefCounter::new(),
send: self.send.clone(),
} }
} }
pub fn upgrade(&mut self, id: WeakRscId<TextureRsc>) -> Option<TextureHandle> { fn push(&mut self, image: DynamicImage) -> u32 {
self.free(); if let Some(i) = self.free.pop() {
let id = self.resources.borrow_mut().upgrade(id)?;
Some(self.handle(id))
}
fn push(&mut self, kind: TextureKind, size: Vec2, image: DynamicImage) -> TextureHandle {
self.free();
let old_capacity = self.resources.borrow().capacity();
let id = self.resources.borrow_mut().add(TextureRsc { kind, size });
let i = id.slot();
if (i as usize) < old_capacity {
self.images[i as usize] = Some(image); self.images[i as usize] = Some(image);
self.kinds[i as usize] = kind; self.updates.push(Update::Set(i));
self.updates.push(Update::Set(kind, i)); i
} else { } else {
let i = self.images.len() as u32;
self.images.push(Some(image)); self.images.push(Some(image));
self.kinds.push(kind); self.updates.push(Update::Push(i));
self.updates.push(Update::Push(kind, i)); i
} }
TextureHandle {
rsc: RscHandle::new(id, self.resources.clone()),
}
}
/// The map keeps its own reference for the life of the `Textures`, so
/// a shared slot is never freed and never reused for something else --
/// which is what makes a handle held by a long-lived widget safe.
pub fn shared(
&mut self,
key: SharedTextureKey,
make: impl FnOnce() -> DynamicImage,
) -> TextureHandle {
if let Some(handle) = self.shared.get(&key) {
return handle.clone();
}
let handle = self.add(make());
self.shared.insert(key, handle.clone());
handle
}
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
self.images[handle.rsc.id().slot() as usize]
.as_mut()
.expect("texture was freed while still held")
}
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
self.updates
.push(Update::Patch(handle.rsc.id().slot(), rect));
}
/// A new device starts with no textures, and the renderer-side mirror
/// of these slots (`render::texture::GpuTextures`) starts empty with
/// it. What it must not do is start empty while the handles widgets
/// are still holding name slots by *index*: `Textures::reset` used to
/// throw this bookkeeping away, which left every live `TextureHandle`
/// -- one per `widget::mark`, hundreds on a transcript screen --
/// pointing at a slot nothing recognised, and the first frame after an
/// Android surface rebuild panicked in `image_bind_group` ("texture
/// slot 89 is not a live standalone image: None"). Re-uploading
/// instead keeps every index meaning what it meant, because this side
/// still holds the images: the slot list is rebuilt identically,
/// including the empty slots, which go across as `PushFree` so the
/// ones after them still land where they were.
pub fn reupload(&mut self) {
self.updates.clear();
self.updates.extend(
(0..self.resources.borrow().capacity() as u32)
.map(|i| Update::Push(self.kinds[i as usize], i)),
);
} }
pub fn free(&mut self) { pub fn free(&mut self) {
let updates = &mut self.updates; for idx in self.recv.try_iter() {
let images = &mut self.images; self.images[idx as usize] = None;
let free_page_layers = &mut self.free_page_layers; self.updates.push(Update::Free(idx));
self.resources.borrow_mut().apply(|id, resource| { self.free.push(idx);
let idx = id.slot();
images[idx as usize] = None;
updates.push(Update::Free(idx));
if let TextureKind::Page { layer } = resource.kind {
free_page_layers.push(layer);
} }
});
} }
pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> { pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> {
self.updates.drain(..).map(|u| match u { self.updates.drain(..).map(|u| match u {
Update::Push(kind, i) => self.images[i as usize] Update::Push(i) => self.images[i as usize]
.as_ref() .as_ref()
.map(|img| TextureUpdate::Push(kind, img)) .map(TextureUpdate::Push)
.unwrap_or(TextureUpdate::PushFree(kind)), .unwrap_or(TextureUpdate::PushFree),
Update::Set(kind, i) => self.images[i as usize] Update::Set(i) => self.images[i as usize]
.as_ref() .as_ref()
.map(|img| TextureUpdate::Set(kind, i, img)) .map(|img| TextureUpdate::Set(i, img))
.unwrap_or(TextureUpdate::SetFree),
Update::Patch(i, rect) => self.images[i as usize]
.as_ref()
.map(|img| TextureUpdate::Patch(i, rect, img))
.unwrap_or(TextureUpdate::SetFree), .unwrap_or(TextureUpdate::SetFree),
Update::Free(i) => TextureUpdate::Free(i), Update::Free(i) => TextureUpdate::Free(i),
}) })
@@ -235,45 +105,27 @@ impl Textures {
} }
impl TextureHandle { impl TextureHandle {
pub fn primitive(&self) -> TexturePrimitive {
self.inner
}
pub fn size(&self) -> Vec2 { pub fn size(&self) -> Vec2 {
self.rsc.get().size self.size
} }
}
/// The bind-group index this handle draws with. Only valid for a impl Drop for TextureHandle {
/// standalone image; an atlas page has no bind group of its own -- it fn drop(&mut self) {
/// samples the shared array via `layer()` instead. Getting this wrong is if self.counter.drop() {
/// a caller bug (the wrong kind of handle reached the wrong draw path), let _ = self.send.send(self.inner.view_idx);
/// not a recoverable condition, so it panics rather than drawing garbage.
pub fn image_index(&self) -> u32 {
match self.rsc.get().kind {
TextureKind::Image => self.rsc.id().slot(),
TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"),
} }
} }
pub fn layer(&self) -> u32 {
match self.rsc.get().kind {
TextureKind::Page { layer } => layer,
TextureKind::Image => panic!("layer() called on a standalone image handle"),
}
}
pub fn strong(&self) -> StrongRscId<TextureRsc> {
self.rsc.strong()
}
pub fn weak(&self) -> WeakRscId<TextureRsc> {
self.rsc.weak()
}
} }
impl Index<&TextureHandle> for Textures { impl Index<&TextureHandle> for Textures {
type Output = DynamicImage; type Output = DynamicImage;
fn index(&self, index: &TextureHandle) -> &Self::Output { fn index(&self, index: &TextureHandle) -> &Self::Output {
self.images[index.rsc.id().slot() as usize] self.images[index.inner.view_idx as usize].as_ref().unwrap()
.as_ref()
.unwrap()
} }
} }
@@ -282,96 +134,3 @@ impl Default for Textures {
Self::new() Self::new()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use image::RgbaImage;
fn image(n: u32) -> DynamicImage {
RgbaImage::new(n, n).into()
}
fn key(id: u64) -> SharedTextureKey {
SharedTextureKey { owner: "test", id }
}
#[test]
fn a_shared_texture_is_built_once_and_handed_out_again() {
let mut textures = Textures::new();
let built = std::cell::Cell::new(0);
let make = |textures: &mut Textures, id: u64| {
textures.shared(key(id), || {
built.set(built.get() + 1);
image(4)
})
};
let first = make(&mut textures, 1);
let again = make(&mut textures, 1);
let other = make(&mut textures, 2);
assert_eq!(built.get(), 2, "the second ask for key 1 rasterised again");
assert_eq!(first.image_index(), again.image_index());
assert_ne!(first.image_index(), other.image_index());
}
#[test]
fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() {
let mut textures = Textures::new();
let slot = textures.shared(key(1), || image(4)).image_index();
textures.free();
let plain = textures.add(image(4));
assert_ne!(
plain.image_index(),
slot,
"an ordinary texture was handed the shared mark's slot"
);
}
#[test]
fn a_released_atlas_page_layer_is_reused_without_moving_live_pages() {
let mut textures = Textures::new();
let first = textures.add_page(image(4));
let second = textures.add_page(image(4));
let first_layer = first.layer();
let second_layer = second.layer();
drop(first);
textures.free();
let replacement = textures.add_page(image(4));
assert_eq!(replacement.layer(), first_layer);
assert_eq!(second.layer(), second_layer);
}
#[test]
fn reupload_replays_every_slot_in_order_including_the_empty_ones() {
let mut textures = Textures::new();
let keep_a = textures.add(image(4));
let dropped = textures.add(image(4));
let keep_b = textures.add(image(4));
let (a, gone, b) = (
keep_a.image_index(),
dropped.image_index(),
keep_b.image_index(),
);
drop(dropped);
textures.free();
assert!(textures.updates().count() > 0);
textures.reupload();
let kinds: Vec<String> = textures
.updates()
.map(|u| match u {
TextureUpdate::Push(..) => "push".to_string(),
TextureUpdate::PushFree(..) => "push-free".to_string(),
_ => "other".to_string(),
})
.collect();
assert_eq!(
kinds,
["push", "push-free", "push"],
"slots {a}, {gone} (freed) and {b} must replay in order, so the \
indices after a hole still land where they were"
);
}
}
-227
View File
@@ -1,227 +0,0 @@
use crate::{
PatchRect, TextureHandle, Textures,
util::{HashMap, Vec2},
};
use image::RgbaImage;
use swash::scale::image::{Content, Image};
pub(crate) const PAGE: u32 = 1024;
pub(crate) const DEFAULT_GLYPH_BUCKET_ID: u64 = 0;
const PAD: u32 = 1;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct GlyphKey {
pub font: u64,
pub glyph: u32,
pub size: u32,
pub subpixel: u8,
pub coords: u64,
}
#[derive(Clone, Copy)]
pub struct GlyphEntry {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
pub left: i32,
pub top: i32,
pub width: u32,
pub height: u32,
pub is_color: bool,
pub layer: u32,
}
struct Page {
handle: TextureHandle,
/// Shelf packing: glyphs are placed left to right along a shelf whose
/// height is the tallest glyph on it, and a new shelf starts above when the
/// row runs out. Chosen over a real packer because glyphs at one size are
/// close to the same height, which is the case shelves are good at.
x: u32,
y: u32,
shelf_height: u32,
}
#[derive(Default)]
struct Bucket {
pages: Vec<Page>,
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
}
#[derive(Default)]
pub struct GlyphAtlas {
buckets: HashMap<u64, Bucket>,
generation: u64,
}
impl GlyphAtlas {
pub(crate) fn get(&self, bucket: u64, key: &GlyphKey) -> Option<Option<GlyphEntry>> {
self.buckets.get(&bucket)?.entries.get(key).copied()
}
/// Rasterised pixels in, a place in the atlas out. `None` means the glyph
/// has no pixels, which is a normal answer rather than a failure.
pub fn insert(
&mut self,
bucket: u64,
key: GlyphKey,
image: &Image,
textures: &mut Textures,
) -> Option<GlyphEntry> {
let bucket = self.buckets.entry(bucket).or_default();
let w = image.placement.width;
let h = image.placement.height;
if w == 0 || h == 0 {
bucket.entries.insert(key, None);
return None;
}
if w + PAD * 2 > PAGE || h + PAD * 2 > PAGE {
// A single glyph larger than a page. Refusing is better than
// silently drawing a cropped one; the caller draws nothing.
bucket.entries.insert(key, None);
return None;
}
let (page_idx, x, y) = bucket.allocate(w, h, textures);
let page = &bucket.pages[page_idx];
let img = textures.image_mut(&page.handle);
let rgba = img.as_mut_rgba8().expect("atlas page is rgba8");
write_glyph(rgba, image, x, y);
let rect = PatchRect {
x,
y,
width: w,
height: h,
};
textures.patch(&page.handle, rect);
let page = &bucket.pages[page_idx];
let scale = 1.0 / PAGE as f32;
let entry = GlyphEntry {
uv_min: [x as f32 * scale, y as f32 * scale],
uv_max: [(x + w) as f32 * scale, (y + h) as f32 * scale],
left: image.placement.left,
top: image.placement.top,
width: w,
height: h,
is_color: matches!(image.content, Content::Color),
layer: page.handle.layer(),
};
bucket.entries.insert(key, Some(entry));
Some(entry)
}
pub(crate) fn insert_empty(&mut self, bucket: u64, key: GlyphKey) {
self.buckets
.entry(bucket)
.or_default()
.entries
.insert(key, None);
}
pub(crate) fn clear_bucket(&mut self, bucket: u64) {
if self.buckets.remove(&bucket).is_some() {
self.generation += 1;
}
}
pub fn generation(&self) -> u64 {
self.generation
}
pub fn page_count(&self) -> usize {
self.buckets.values().map(|bucket| bucket.pages.len()).sum()
}
pub fn glyph_count(&self) -> usize {
self.buckets
.values()
.map(|bucket| bucket.entries.len())
.sum()
}
pub fn clear(&mut self) {
self.buckets.clear();
self.generation += 1;
}
}
impl Bucket {
fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) {
let need_w = w + PAD;
let need_h = h + PAD;
if let Some(i) = self.pages.iter().position(|p| fits(p, need_w, need_h)) {
let page = &mut self.pages[i];
if page.x + need_w > PAGE {
page.y += page.shelf_height;
page.x = PAD;
page.shelf_height = 0;
}
let (x, y) = (page.x, page.y);
page.x += need_w;
page.shelf_height = page.shelf_height.max(need_h);
return (i, x, y);
}
let handle = textures.add_page(RgbaImage::new(PAGE, PAGE));
self.pages.push(Page {
handle,
x: PAD + w + PAD,
y: PAD,
shelf_height: h + PAD,
});
(self.pages.len() - 1, PAD, PAD)
}
}
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
(page.x + need_w <= PAGE && page.y + need_h <= PAGE)
|| (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE)
}
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
let w = image.placement.width;
let h = image.placement.height;
match image.content {
Content::Mask => {
for row in 0..h {
for col in 0..w {
let a = image.data[(row * w + col) as usize];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
Content::Color => {
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let px = [
image.data[i],
image.data[i + 1],
image.data[i + 2],
image.data[i + 3],
];
page.put_pixel(x + col, y + row, image::Rgba(px));
}
}
}
Content::SubpixelMask => {
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let a = image.data[i + 1];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
}
}
#[derive(Clone, Copy)]
pub struct PlacedGlyph {
pub entry: GlyphEntry,
pub offset: Vec2,
pub paint: u32,
}
+16 -44
View File
@@ -15,15 +15,25 @@ pub struct PrimitiveInstance {
pub binding: u32, pub binding: u32,
pub idx: u32, pub idx: u32,
pub mask_idx: MaskIdx, pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
} }
pub fn instance_slot_layout() -> VertexBufferLayout<'static> { impl PrimitiveInstance {
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32]; const ATTRIBS: [VertexAttribute; 7] = vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
3 => Float32x2,
4 => Uint32,
5 => Uint32,
6 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
VertexBufferLayout { VertexBufferLayout {
array_stride: std::mem::size_of::<u32>() as BufferAddress, array_stride: std::mem::size_of::<Self>() as BufferAddress,
step_mode: VertexStepMode::Instance, step_mode: VertexStepMode::Instance,
attributes: &ATTRIBS, attributes: &Self::ATTRIBS,
}
} }
} }
@@ -33,46 +43,8 @@ impl MaskIdx {
pub const NONE: Self = Self::preset(u32::MAX); pub const NONE: Self = Self::preset(u32::MAX);
} }
pub type MoveIdx = Id<u32>;
#[repr(C)] #[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mask { pub struct Mask {
pub primitive: u32, pub region: UiRegion,
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
/// clipping nests: the fragment stage walks the chain and multiplies
/// every coverage on it, which is what makes a pixel inside two
/// feathered corners dimmed by both. Chained rather than intersected
/// on the CPU because each mask moves with its own widget -- a code
/// fence inside a transcript row carries the row's scroll, the list's
/// own box does not, and one region resolved when the fence was last
/// drawn gets the second of those wrong as soon as the row moves.
pub parent: MaskIdx,
}
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
/// a `vec2<f32>`, which gives the struct an 8-byte alignment and rounds its
/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 --
/// the same trap `GlyphPrimitive` documents below. `bytemuck` does not
/// check this for us, and getting it wrong is a wgpu validation panic at
/// draw time ("buffer bound ... with size 12 where the shader expects 16"),
/// not a compile error.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MoveOffset {
pub delta: [f32; 2],
pub parent: u32,
_pad: u32,
}
impl MoveOffset {
pub const NONE_PARENT: u32 = u32::MAX;
pub fn new(delta: [f32; 2], parent: u32) -> Self {
Self {
delta,
parent,
_pad: 0,
}
}
} }
-776
View File
@@ -1,776 +0,0 @@
use std::time::{Duration, Instant};
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
/// report and a percentage from `gfxinfo` mean the same thing. Only a
/// fallback now that a caller can read the display's real refresh rate
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
/// 90Hz or 120Hz phone judged against this constant would call every frame
/// "late" that merely met its own, faster budget.
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
const RING_CAPACITY: usize = 16384;
const MIN_CADENCE_SAMPLES: usize = 12;
struct PhaseMark {
name: String,
start_index: u64,
start_at: Instant,
}
pub struct PhaseStats {
pub name: String,
pub frames: u64,
pub duration: Duration,
/// On a backend that blocks in `present()` rather than in the
/// acquire -- GLES, and so this repo's emulator -- the wait lands in
/// `submit` instead and this over-counts. Named rather than
/// corrected, since correcting it would mean guessing which part of
/// `submit` was a wait.
pub late: u64,
pub late_percent: f64,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// This phase's own medians of the three parts a frame is made of --
/// see [`FrameParts`]. Per phase as well as per run because the parts
/// do not divide the same way in every phase: a fling frame spends
/// most of itself in `acquire` (waiting its turn at the swapchain,
/// which is the display pacing the app and not work) while a
/// streaming frame spends it in `build`, and a run-wide median cannot
/// say that.
/// Vsyncs that went by with no frame produced for them, counted from
/// the gap between consecutive frames rather than from their cost.
pub missed: u64,
pub build_p50: Duration,
pub acquire_p50: Duration,
pub submit_p50: Duration,
pub complete: bool,
}
impl std::fmt::Display for PhaseStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
" {}: {} frames over {:.1}s{}",
self.name,
self.frames,
self.duration.as_secs_f64(),
if self.complete {
""
} else {
" (ring evicted some of this phase)"
},
)?;
writeln!(
f,
" late: {} ({:.1}%) missed vsyncs: {}",
self.late, self.late_percent, self.missed,
)?;
writeln!(
f,
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
)?;
writeln!(
f,
" build p50 {:.1}ms acquire p50 {:.1}ms submit p50 {:.1}ms",
self.build_p50.as_secs_f64() * 1000.0,
self.acquire_p50.as_secs_f64() * 1000.0,
self.submit_p50.as_secs_f64() * 1000.0,
)?;
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
}
}
/// The parts one frame's wall time divides into, measured rather than
/// inferred: what a caller hands [`FrameReport::record`].
#[derive(Clone, Copy, Default, Debug)]
pub struct FrameParts {
pub total: Duration,
pub acquire: Duration,
pub submit: Duration,
}
impl FrameParts {
/// A frame measured as one span, with no parts -- honest for a caller
/// that never measured them (they read as zero and `build` reads as
/// the whole frame) rather than fabricating a split.
pub fn whole(total: Duration) -> Self {
Self {
total,
acquire: Duration::ZERO,
submit: Duration::ZERO,
}
}
/// The two waits a renderer's `draw` measures, with `total` left at
/// zero for the frame loop around it to fill in -- it is the only
/// caller that knows when the frame started.
pub fn waits(acquire: Duration, submit: Duration) -> Self {
Self {
total: Duration::ZERO,
acquire,
submit,
}
}
/// What is left once the two measured waits are taken off: laying
/// out, shaping text, building primitives and recording the render
/// pass. Saturating, since the three come from different `Instant`
/// pairs on a clock a caller owns.
pub fn build(&self) -> Duration {
self.total
.saturating_sub(self.acquire)
.saturating_sub(self.submit)
}
pub fn work(&self) -> Duration {
self.total.saturating_sub(self.acquire)
}
}
/// **What this does not measure**: wgpu's `present()` call queues the frame
/// with the compositor and returns; it is not fenced against the GPU
/// actually finishing the frame or the compositor actually showing it, the
/// way `gfxinfo`'s own `GPU_DURATION`/vsync accounting is. So a sample here
/// is "how long the CPU took to build and submit this frame", not
/// "how long the frame took to reach the screen" -- named in
/// [`FrameStats`]'s own `Display` line rather than presented as the latter,
/// per the standing rule against showing an inferred number as a measured
/// one where the two differ.
pub struct FrameReport {
ring: Box<[Duration; RING_CAPACITY]>,
submit_ring: Box<[Duration; RING_CAPACITY]>,
/// The `acquire` half of each sample in `ring`, same index, same
/// lifetime -- see [`FrameParts::acquire`], which is the part that is
/// a wait rather than work.
acquire_ring: Box<[Duration; RING_CAPACITY]>,
/// How long before each sample the *previous* frame was, same index,
/// same lifetime -- the frame's own cadence rather than its cost. See
/// [`PhaseStats::missed`] for why a report needs both.
gap_ring: Box<[Duration; RING_CAPACITY]>,
last_frame: Option<(Instant, bool)>,
index_ring: Box<[u64; RING_CAPACITY]>,
len: usize,
pos: usize,
total_frames: u64,
janky_frames: u64,
phases: Vec<PhaseMark>,
}
pub struct FrameStats {
pub total_frames: u64,
pub janky_percent: f64,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
pub cpu_p50: Duration,
pub acquire_p50: Duration,
pub gpu_wait_p50: Duration,
}
impl std::fmt::Display for FrameStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"frames={} janky%={:.2} p50={:.1}ms p90={:.1}ms p99={:.1}ms worst={:.1}ms \
(measures redraw-start to after present() is called, not GPU/compositor \
completion)",
self.total_frames,
self.janky_percent,
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
self.worst.as_secs_f64() * 1000.0,
)?;
write!(
f,
" cpu_p50={:.1}ms acquire_p50={:.1}ms gpu_wait_p50={:.1}ms (own work vs. \
waiting for a swapchain image vs. submit-to-after-present)",
self.cpu_p50.as_secs_f64() * 1000.0,
self.acquire_p50.as_secs_f64() * 1000.0,
self.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
}
impl FrameReport {
pub fn new() -> Self {
Self {
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
acquire_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
gap_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
last_frame: None,
index_ring: Box::new([0; RING_CAPACITY]),
len: 0,
pos: 0,
total_frames: 0,
janky_frames: 0,
phases: Vec::new(),
}
}
/// One entry point rather than one per shape of measurement: a caller
/// with nothing but a total passes `FrameParts::whole(total)`, which
/// says so in the type instead of leaving the report to guess from a
/// zero.
pub fn record(&mut self, at: Instant, parts: FrameParts, animating: bool) {
self.gap_ring[self.pos] = match self.last_frame {
Some((last, true)) => at.saturating_duration_since(last),
Some((_, false)) | None => Duration::ZERO,
};
self.last_frame = Some((at, animating));
self.ring[self.pos] = parts.total;
self.submit_ring[self.pos] = parts.submit;
self.acquire_ring[self.pos] = parts.acquire;
self.index_ring[self.pos] = self.total_frames;
self.pos = (self.pos + 1) % RING_CAPACITY;
self.len = (self.len + 1).min(RING_CAPACITY);
self.total_frames += 1;
if parts.total > JANK_THRESHOLD {
self.janky_frames += 1;
}
}
pub fn reset(&mut self) {
self.len = 0;
self.pos = 0;
self.total_frames = 0;
self.janky_frames = 0;
self.last_frame = None;
self.phases.clear();
}
fn parts(&self, slot: usize) -> FrameParts {
FrameParts {
total: self.ring[slot],
acquire: self.acquire_ring[slot],
submit: self.submit_ring[slot],
}
}
pub fn mark_phase(&mut self, name: &str) {
debug_assert!(
self.phases
.last()
.is_none_or(|p| self.total_frames >= p.start_index)
);
self.phases.push(PhaseMark {
name: name.to_string(),
start_index: self.total_frames,
start_at: Instant::now(),
});
}
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
if self.phases.is_empty() || refresh_hz <= 0.0 {
return Vec::new();
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
self.phases
.iter()
.enumerate()
.map(|(i, phase)| {
let (end_index, end_at) = match self.phases.get(i + 1) {
Some(next) => (next.start_index, next.start_at),
None => (self.total_frames, now),
};
let frames = end_index.saturating_sub(phase.start_index);
let slots: Vec<usize> = (0..self.len)
.filter(|&j| {
let idx = self.index_ring[j];
idx >= phase.start_index && idx < end_index
})
.collect();
let mut samples: Vec<Duration> = slots.iter().map(|&j| self.ring[j]).collect();
let complete = samples.len() as u64 >= frames;
if samples.is_empty() {
return PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late: 0,
late_percent: 0.0,
p50: Duration::ZERO,
p90: Duration::ZERO,
p99: Duration::ZERO,
worst: Duration::ZERO,
missed: 0,
build_p50: Duration::ZERO,
acquire_p50: Duration::ZERO,
submit_p50: Duration::ZERO,
complete,
};
}
let part_p50 = |part: &dyn Fn(usize) -> Duration| {
let mut v: Vec<Duration> = slots.iter().map(|&j| part(j)).collect();
v.sort_unstable();
v[v.len() / 2]
};
// **The phase's own first frame is skipped**: its gap
// reaches back into the previous phase, across whatever
// the run did between the two -- a bench pausing a second
// between phases would otherwise open each one with sixty
// "missed" frames nobody was waiting for.
let missed: u64 = slots
.iter()
.filter(|&&j| self.index_ring[j] > phase.start_index)
.map(|&j| self.gap_ring[j])
.filter(|gap| !gap.is_zero())
.filter(|gap| *gap > budget.mul_f64(1.5))
.map(|gap| (gap.as_secs_f64() / budget.as_secs_f64()).round() as u64 - 1)
.sum();
let build_p50 = part_p50(&|j| self.parts(j).build());
let acquire_p50 = part_p50(&|j| self.acquire_ring[j]);
let submit_p50 = part_p50(&|j| self.submit_ring[j]);
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
let late = slots
.iter()
.filter(|&&j| self.parts(j).work() > budget)
.count() as u64;
PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late,
late_percent: 100.0 * late as f64 / samples.len() as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("checked not empty above"),
missed,
build_p50,
acquire_p50,
submit_p50,
complete,
}
})
.collect()
}
/// **This is a floor on the display's refresh rate, never a reading
/// of it.** You cannot observe a cadence faster than you draw, so an
/// app that never keeps up says nothing about the panel; a caller
/// resolves it by taking whichever of this and the platform's own
/// answer is *larger*. That matters in both directions and each has
/// been seen: `Display.getRefreshRate()` answered 60 for a run that
/// sustained 120.3fps, because a phone that varies its rate answers
/// with whatever mode it happens to be in when asked -- and this
/// answered 88 on an emulator whose display is 60Hz and whose app
/// managed 51, because an earlier version took the fastest tenth of
/// the gaps rather than the sustained rate. The fastest tenth is a
/// measurement of the best moment; the budget wants the rhythm.
///
/// `None` under `MIN_CADENCE_SAMPLES` measurable gaps, which is the
/// honest answer for a run too short or too idle to have seen one.
pub fn sustained_frame_hz(&self) -> Option<f32> {
let gaps = self.gap_ring[..self.len].iter().filter(|g| !g.is_zero());
let (count, total) = gaps.fold((0u32, Duration::ZERO), |(n, sum), g| (n + 1, sum + *g));
if (count as usize) < MIN_CADENCE_SAMPLES || total.is_zero() {
return None;
}
Some(count as f32 / total.as_secs_f32())
}
/// `None` if nothing has been recorded since the last reset -- the
/// "no frames recorded, scroll first" case, not a zeroed report that
/// would read as a real (perfect) measurement.
pub fn report(&self) -> Option<FrameStats> {
if self.len == 0 {
return None;
}
let mut samples: Vec<Duration> = self.ring[..self.len].to_vec();
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
let submit_samples: Vec<Duration> = self.submit_ring[..self.len].to_vec();
let acquire_samples: Vec<Duration> = self.acquire_ring[..self.len].to_vec();
let cpu_samples: Vec<Duration> = (0..self.len).map(|j| self.parts(j).build()).collect();
let median = |mut v: Vec<Duration>| {
v.sort_unstable();
v[v.len() / 2]
};
Some(FrameStats {
total_frames: self.total_frames,
janky_percent: 100.0 * self.janky_frames as f64 / self.total_frames as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("len > 0 checked above"),
cpu_p50: median(cpu_samples),
acquire_p50: median(acquire_samples),
gpu_wait_p50: median(submit_samples),
})
}
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
if self.len == 0 || refresh_hz <= 0.0 {
return (0, 0.0);
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
let late = (0..self.len)
.filter(|&j| self.parts(j).work() > budget)
.count() as u64;
(late, 100.0 * late as f64 / self.len as f64)
}
}
impl Default for FrameReport {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_frames_reports_none() {
assert!(FrameReport::new().report().is_none());
}
#[test]
fn one_frame_is_every_percentile_and_the_worst() {
let mut r = FrameReport::new();
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(10)),
true,
);
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 1);
assert_eq!(stats.p50, Duration::from_millis(10));
assert_eq!(stats.p99, Duration::from_millis(10));
assert_eq!(stats.worst, Duration::from_millis(10));
assert_eq!(stats.janky_percent, 0.0);
}
#[test]
fn percentiles_and_worst_over_a_known_set() {
let mut r = FrameReport::new();
for ms in (1..=100).rev() {
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(ms)),
true,
);
}
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 100);
assert_eq!(stats.p50, Duration::from_millis(51));
assert_eq!(stats.p90, Duration::from_millis(91));
assert_eq!(stats.p99, Duration::from_millis(100));
assert_eq!(stats.worst, Duration::from_millis(100));
}
#[test]
fn jank_threshold_matches_gfxinfos_60hz_budget() {
let mut r = FrameReport::new();
r.record(
Instant::now(),
FrameParts::whole(Duration::from_nanos(16_666_667)),
true,
); // exactly on budget: not janky
r.record(
Instant::now(),
FrameParts::whole(Duration::from_nanos(16_666_668)),
true,
); // one ns over: janky
let stats = r.report().unwrap();
assert_eq!(stats.janky_percent, 50.0);
}
#[test]
fn janky_percent_is_over_all_time_frames_not_just_the_ring() {
let mut r = FrameReport::new();
for _ in 0..10 {
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(50)),
true,
);
}
assert_eq!(r.report().unwrap().janky_percent, 100.0);
r.reset();
assert!(r.report().is_none());
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(1)),
true,
);
assert_eq!(r.report().unwrap().janky_percent, 0.0);
}
#[test]
fn record_without_a_split_reports_the_whole_frame_as_cpu() {
let mut r = FrameReport::new();
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(20)),
true,
);
let stats = r.report().unwrap();
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
assert_eq!(stats.gpu_wait_p50, Duration::ZERO);
}
#[test]
fn each_part_reports_its_own_median_and_build_excludes_the_wait() {
let mut r = FrameReport::new();
for (acquire, submit) in [(5, 2), (10, 3), (20, 4)] {
r.record(
Instant::now(),
FrameParts {
total: Duration::from_millis(30),
acquire: Duration::from_millis(acquire),
submit: Duration::from_millis(submit),
},
true,
);
}
let stats = r.report().unwrap();
assert_eq!(stats.p50, Duration::from_millis(30));
assert_eq!(stats.acquire_p50, Duration::from_millis(10));
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(3));
assert_eq!(stats.cpu_p50, Duration::from_millis(17));
}
#[test]
fn a_gap_of_more_than_one_vsync_is_counted_as_a_missed_frame() {
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
r.mark_phase("fling");
for step in [0u32, 1, 2, 4, 5, 8] {
r.record(
base + budget * step,
FrameParts::whole(Duration::from_millis(2)),
true,
);
}
let phase = r.phase_stats(Instant::now(), 60.0).remove(0);
assert_eq!(phase.late, 0, "no frame here was over its budget");
assert_eq!(phase.missed, 3);
}
#[test]
fn an_idle_gap_is_not_a_missed_frame() {
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
r.mark_phase("fling");
r.record(base, FrameParts::whole(Duration::ZERO), true);
r.record(base + budget, FrameParts::whole(Duration::ZERO), true);
r.record(base + budget * 2, FrameParts::whole(Duration::ZERO), false);
r.record(
base + Duration::from_millis(300),
FrameParts::whole(Duration::ZERO),
true,
);
let phase = r.phase_stats(Instant::now(), 60.0).remove(0);
assert_eq!(
phase.missed, 0,
"a rest nobody was waiting through is not a stutter"
);
}
#[test]
fn a_sustained_120hz_run_measures_120_whatever_the_platform_says() {
let mut r = FrameReport::new();
let base = Instant::now();
let period = Duration::from_nanos(8_333_333);
for step in 0..120u32 {
r.record(
base + period * step,
FrameParts::whole(Duration::ZERO),
true,
);
}
let hz = r.sustained_frame_hz().expect("120 gaps is plenty");
assert!((hz - 120.0).abs() < 1.0, "measured {hz}Hz, expected ~120");
}
#[test]
fn an_app_that_cannot_keep_up_does_not_claim_a_faster_display() {
let mut r = FrameReport::new();
let base = Instant::now();
let mut at = base;
for step in 0..120u32 {
at += if step % 10 == 0 {
Duration::from_millis(8)
} else {
Duration::from_millis(20)
};
r.record(at, FrameParts::whole(Duration::ZERO), true);
}
let hz = r.sustained_frame_hz().expect("120 gaps is plenty");
assert!(
hz < 60.0,
"measured {hz}Hz, which claims more than was drawn"
);
}
#[test]
fn a_frame_held_back_by_the_display_is_not_late() {
let mut r = FrameReport::new();
let base = Instant::now();
let period = Duration::from_nanos(8_333_333);
r.mark_phase("fling");
for step in 0..30u32 {
r.record(
base + period * step,
FrameParts {
total: Duration::from_micros(8_300),
acquire: Duration::from_micros(7_900),
submit: Duration::from_micros(200),
},
true,
);
}
let phase = r.phase_stats(Instant::now(), 120.0).remove(0);
assert_eq!(phase.late, 0);
assert_eq!(r.late_at_hz(120.0).0, 0);
}
#[test]
fn a_phase_does_not_inherit_the_pause_before_it() {
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
r.mark_phase("fling");
for step in [0u32, 1, 2] {
r.record(
base + budget * step,
FrameParts::whole(Duration::ZERO),
true,
);
}
let after = base + Duration::from_secs(1);
r.mark_phase("type");
for step in [0u32, 1, 2] {
r.record(
after + budget * step,
FrameParts::whole(Duration::ZERO),
true,
);
}
let phases = r.phase_stats(Instant::now(), 60.0);
assert_eq!(phases[0].missed, 0);
assert_eq!(
phases[1].missed, 0,
"the rest between phases is not a stutter"
);
}
#[test]
fn ring_wraps_without_growing_past_capacity() {
let mut r = FrameReport::new();
for i in 0..(RING_CAPACITY * 2) {
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(1 + (i % 5) as u64)),
true,
);
}
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64);
assert!(stats.worst <= Duration::from_millis(5));
}
#[test]
fn no_marks_means_no_phases() {
let mut r = FrameReport::new();
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(5)),
true,
);
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn phases_slice_frames_by_when_they_were_marked() {
let mut r = FrameReport::new();
r.mark_phase("a");
for _ in 0..5 {
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(10)),
true,
); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
}
r.mark_phase("b");
for _ in 0..3 {
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(20)),
true,
); // 20ms: late at 60Hz
}
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 2);
assert_eq!(phases[0].name, "a");
assert_eq!(phases[0].frames, 5);
assert_eq!(phases[0].late, 0);
assert_eq!(phases[0].worst, Duration::from_millis(10));
assert_eq!(phases[1].name, "b");
assert_eq!(phases[1].frames, 3);
assert_eq!(phases[1].late, 3);
assert_eq!(phases[1].late_percent, 100.0);
assert_eq!(phases[1].worst, Duration::from_millis(20));
assert!(phases[0].complete);
assert!(phases[1].complete);
}
#[test]
fn the_last_phase_runs_until_now() {
let mut r = FrameReport::new();
r.mark_phase("only");
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(1)),
true,
);
std::thread::sleep(Duration::from_millis(20));
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 1);
assert!(phases[0].duration >= Duration::from_millis(20));
}
#[test]
fn reset_clears_phase_marks() {
let mut r = FrameReport::new();
r.mark_phase("a");
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(1)),
true,
);
r.reset();
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
let mut r = FrameReport::new();
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(10)),
true,
);
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
}
}
+109 -415
View File
@@ -1,132 +1,30 @@
use std::num::NonZero;
use crate::{ use crate::{
Ui, UiData, Ui,
render::{ render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf},
data::{PrimitiveInstance, instance_slot_layout}, util::HashMap,
texture::GpuTextures,
util::ArrBuf,
},
util::{HashMap, Vec2},
}; };
use data::WindowUniform; use data::WindowUniform;
use pollster::FutureExt;
use wgpu::{ use wgpu::{
util::{BufferInitDescriptor, DeviceExt}, util::{BufferInitDescriptor, DeviceExt},
*, *,
}; };
use winit::dpi::PhysicalSize;
mod atlas;
mod data; mod data;
mod frame_report;
mod primitive; mod primitive;
mod sdf;
mod texture; mod texture;
mod util; mod util;
pub use atlas::*; pub use data::{Mask, MaskIdx};
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use frame_report::{FrameParts, FrameReport, FrameStats, JANK_THRESHOLD};
pub use primitive::*; pub use primitive::*;
pub use sdf::{distance_from_rect, rounded_rect_coverage};
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// The advertised swapchain format and the sRGB view Iris renders through.
/// A backend may advertise only the non-sRGB member of an RGBA/BGRA pair;
/// wgpu permits its sRGB counterpart as a configured view format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SurfaceFormat {
pub surface: TextureFormat,
pub view: TextureFormat,
}
pub fn srgb_surface_format(caps: &SurfaceCapabilities) -> Result<SurfaceFormat, String> {
let supports_srgb_space = |format| caps.color_spaces(format).contains(SurfaceColorSpaces::SRGB);
if let Some(surface) = caps
.formats
.iter()
.copied()
.find(|format| format.is_srgb() && supports_srgb_space(*format))
{
return Ok(SurfaceFormat {
surface,
view: surface,
});
}
if let Some(surface) = caps
.formats
.iter()
.copied()
.find(|format| format.add_srgb_suffix().is_srgb() && supports_srgb_space(*format))
{
return Ok(SurfaceFormat {
surface,
view: surface.add_srgb_suffix(),
});
}
Err(format!(
"the surface has no RGBA/BGRA format with an sRGB render view and sRGB output colour \
space; advertised default formats: {:?}",
caps.formats
))
}
pub fn device_limits() -> Limits {
Limits {
max_buffer_size: 1 << 30,
max_compute_workgroup_storage_size: 0,
max_compute_invocations_per_workgroup: 0,
max_compute_workgroup_size_x: 0,
max_compute_workgroup_size_y: 0,
max_compute_workgroup_size_z: 0,
max_compute_workgroups_per_dimension: 0,
..Default::default()
}
}
#[derive(Clone)]
pub struct WgpuErrorLog {
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
}
const WGPU_ERROR_LOG_CAP: usize = 20;
impl Default for WgpuErrorLog {
fn default() -> Self {
Self {
errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
}
}
}
impl WgpuErrorLog {
pub fn record(&self, error: impl std::fmt::Display) {
let mut errors = self.errors.lock().unwrap();
if errors.len() >= WGPU_ERROR_LOG_CAP {
errors.pop_front();
}
errors.push_back(error.to_string());
}
/// A snapshot for the Diagnostics page -- cloned rather than held,
/// since the lock must not outlive one call.
pub fn snapshot(&self) -> Vec<String> {
self.errors.lock().unwrap().iter().cloned().collect()
}
pub fn len(&self) -> usize {
self.errors.lock().unwrap().len()
}
pub fn is_empty(&self) -> bool {
self.errors.lock().unwrap().is_empty()
}
}
pub struct UiRenderNode { pub struct UiRenderNode {
uniform_group: BindGroup, uniform_group: BindGroup,
primitive_layout: BindGroupLayout, primitive_layout: BindGroupLayout,
primitives: PrimitiveBuffers,
primitive_group: BindGroup,
rsc_layout: BindGroupLayout, rsc_layout: BindGroupLayout,
rsc_group: BindGroup, rsc_group: BindGroup,
@@ -136,149 +34,88 @@ pub struct UiRenderNode {
active: Vec<usize>, active: Vec<usize>,
window_buffer: Buffer, window_buffer: Buffer,
textures: GpuTextures, textures: GpuTextures,
instances: ArrBuf<PrimitiveInstance>,
masks: ArrBuf<Mask>, masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
paints: ArrBuf<crate::LinearRgba>,
masks_layout: BindGroupLayout,
masks_group: BindGroup,
} }
struct RenderLayer { struct RenderLayer {
order: ArrBuf<u32>, instance: ArrBuf<PrimitiveInstance>,
/// A standalone image's slots, kept apart from `order` because each primitives: PrimitiveBuffers,
/// one draws with its own bind group -- see `UiRenderNode::draw`. primitive_group: BindGroup,
images: ArrBuf<u32>,
/// The texture slot each entry of `images` draws with, in the same
/// order, refreshed alongside it. Not in the vertex buffer itself
/// because it names a bind group, not shader data.
image_tex_indices: Vec<u32>,
} }
impl UiRenderNode { impl UiRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) { pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_pipeline(&self.pipeline); pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_group, &[]); pass.set_bind_group(0, &self.uniform_group, &[]);
pass.set_bind_group(1, &self.primitive_group, &[]); pass.set_bind_group(2, &self.rsc_group, &[]);
pass.set_bind_group(3, &self.masks_group, &[]);
for i in &self.active { for i in &self.active {
let layer = &self.layers[i]; let layer = &self.layers[i];
if layer.order.len() == 0 && layer.images.len() == 0 { if layer.instance.len() == 0 {
continue; continue;
} }
if layer.order.len() > 0 { pass.set_bind_group(1, &layer.primitive_group, &[]);
pass.set_bind_group(2, &self.rsc_group, &[]); pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
pass.set_vertex_buffer(0, layer.order.buffer.slice(..)); pass.draw(0..4, 0..layer.instance.len() as u32);
pass.draw(0..4, 0..layer.order.len() as u32);
}
// Images draw after this layer's rects and glyphs, one draw call
// each with its own bind group. That draws every image "on top"
// within the layer, which loses nothing that currently exists:
// `Primitives::apply_free` frees with `swap_remove`, so a layer's
// draw order was already undefined before images had their own
// list -- nothing before this relied on interleaving a rect
// between two images at a particular position.
if layer.images.len() > 0 {
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
pass.draw(0..4, k as u32..k as u32 + 1);
}
}
} }
} }
pub fn update(&mut self, device: &Device, queue: &Queue, ui: &mut Ui) -> FrameUpdateStats { pub fn update(&mut self, device: &Device, queue: &Queue, ui: &mut Ui) {
let render_handle = ui.render_state.clone();
let mut render_guard = render_handle.get_mut();
let ui_render = &mut *render_guard;
let ui_data: &mut UiData = ui;
self.active.clear(); self.active.clear();
for (i, order) in ui_render.layers.iter_mut() { for (i, primitives) in ui.layers.iter_mut() {
self.active.push(i); self.active.push(i);
let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer { for change in primitives.apply_free() {
order: ArrBuf::new( if let Some(inst) = ui.active.get_mut(&change.id) {
for h in &mut inst.primitives {
if h.layer == i && h.inst_idx == change.old {
h.inst_idx = change.new;
break;
}
}
}
}
let rlayer = self.layers.entry(i).or_insert_with(|| {
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &self.primitive_layout, primitives.buffers());
RenderLayer {
instance: ArrBuf::new(
device, device,
BufferUsages::VERTEX | BufferUsages::COPY_DST, BufferUsages::VERTEX | BufferUsages::COPY_DST,
"layer order", "instance",
), ),
images: ArrBuf::new( primitives,
device, primitive_group,
BufferUsages::VERTEX | BufferUsages::COPY_DST, }
"layer image order",
),
image_tex_indices: Vec::new(),
}); });
if order.updated { if primitives.updated {
let (entries, dirty) = order.order_for_upload(); rlayer
rlayer.order.update(device, queue, entries, dirty); .instance
let (entries, dirty) = order.images_for_upload(); .update(device, queue, primitives.instances());
rlayer.images.update(device, queue, entries, dirty); rlayer.primitives.update(device, queue, primitives.data());
rlayer.image_tex_indices.clear(); rlayer.primitive_group = Self::primitive_group(
rlayer.image_tex_indices.extend(
order
.images()
.iter()
.map(|&slot| ui_render.primitives.instance(slot).idx),
);
order.updated = false;
}
}
let instances_resized = if ui_render.primitives.needs_upload() {
let (entries, dirty) = ui_render.primitives.instances_for_upload();
let resized = self.instances.update(device, queue, entries, dirty);
if self
.primitives
.update(device, queue, ui_render.primitives.data_mut())
{
self.primitive_group = Self::primitive_group(
device, device,
&self.primitive_layout, &self.primitive_layout,
self.primitives.buffers(), rlayer.primitives.buffers(),
); );
primitives.updated = false;
} }
resized
} else {
false
};
let (entries, dirty) = ui_data.masks.for_upload();
let masks_resized = self.masks.update(device, queue, entries, dirty);
let (entries, dirty) = ui_data.move_offsets.for_upload();
let moves_resized = self.move_offsets.update(device, queue, entries, dirty);
let (entries, dirty) = ui_data.paints.for_upload();
let paints_resized = self.paints.update(device, queue, entries, dirty);
if masks_resized || moves_resized || instances_resized || paints_resized {
self.masks_group = Self::masks_group(
device,
&self.masks_layout,
&self.masks,
&self.move_offsets,
&self.instances,
&self.paints,
);
} }
let rebuild_main = self let mut changed = false;
.textures changed |= self.textures.update(&mut ui.textures);
.update(&mut ui_data.textures, &self.rsc_layout); if ui.masks.changed {
if rebuild_main { ui.masks.changed = false;
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures); self.masks.update(device, queue, &ui.masks[..]);
changed = true;
} }
FrameUpdateStats { if changed {
masks_resized, self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks);
moves_resized,
paints_resized,
} }
} }
/// Takes a size rather than a window type: this is the only thing the pub fn resize(&mut self, size: &PhysicalSize<u32>, queue: &Queue) {
/// core wanted from winit, and depending on a windowing backend for two
/// numbers is what put `android-activity` in the core's graph for an
/// Android build that is meant to go through android-view instead.
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
let size = size.into();
let slice = &[WindowUniform { let slice = &[WindowUniform {
width: size.x, width: size.width as f32,
height: size.y, height: size.height as f32,
}]; }];
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
} }
@@ -286,25 +123,15 @@ impl UiRenderNode {
pub fn new( pub fn new(
device: &Device, device: &Device,
queue: &Queue, queue: &Queue,
target_format: TextureFormat, config: &SurfaceConfiguration,
window_size: impl Into<Vec2>, limits: UiLimits,
) -> Result<Self, String> { ) -> Self {
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
let shader = device.create_shader_module(ShaderModuleDescriptor { let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("UI Shape Shader"), label: Some("UI Shape Shader"),
source: ShaderSource::Wgsl(SHAPE_SHADER.into()), source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
}); });
let window_uniform = { let window_uniform = WindowUniform::default();
let size = window_size.into();
WindowUniform {
width: size.x,
height: size.y,
}
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor { let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"), label: Some("window"),
contents: bytemuck::cast_slice(&[window_uniform]), contents: bytemuck::cast_slice(&[window_uniform]),
@@ -328,8 +155,9 @@ impl UiRenderNode {
let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer); let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer);
let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &PrimitiveBuffers::BINDINGS.map(|binding| BindGroupLayoutEntry { entries: &core::array::from_fn::<_, { PrimitiveBuffers::LEN }, _>(|i| {
binding, BindGroupLayoutEntry {
binding: i as u32,
visibility: ShaderStages::FRAGMENT, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer { ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true }, ty: BufferBindingType::Storage { read_only: true },
@@ -337,56 +165,25 @@ impl UiRenderNode {
min_binding_size: None, min_binding_size: None,
}, },
count: None, count: None,
}
}), }),
label: Some("primitive"), label: Some("primitive"),
}); });
let tex_manager = GpuTextures::new(device, queue); let tex_manager = GpuTextures::new(device, queue);
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &primitive_layout, primitives.buffers());
let instances = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui instances",
);
let masks = ArrBuf::new( let masks = ArrBuf::new(
device, device,
BufferUsages::STORAGE | BufferUsages::COPY_DST, BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks", "ui masks",
); );
let move_offsets = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let paints = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui paints",
);
let rsc_layout = Self::rsc_layout(device); let rsc_layout = Self::rsc_layout(device, &limits);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager); let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks);
let masks_layout = Self::masks_layout(device);
let masks_group = Self::masks_group(
device,
&masks_layout,
&masks,
&move_offsets,
&instances,
&paints,
);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"), label: Some("UI Shape Pipeline Layout"),
bind_group_layouts: &[ bind_group_layouts: &[&uniform_layout, &primitive_layout, &rsc_layout],
Some(&uniform_layout), push_constant_ranges: &[],
Some(&primitive_layout),
Some(&rsc_layout),
Some(&masks_layout),
],
immediate_size: 0,
}); });
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor { let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
label: Some("UI Shape Pipeline"), label: Some("UI Shape Pipeline"),
@@ -394,14 +191,14 @@ impl UiRenderNode {
vertex: VertexState { vertex: VertexState {
module: &shader, module: &shader,
entry_point: Some("vs_main"), entry_point: Some("vs_main"),
buffers: &[Some(instance_slot_layout())], buffers: &[PrimitiveInstance::desc()],
compilation_options: Default::default(), compilation_options: Default::default(),
}, },
fragment: Some(FragmentState { fragment: Some(FragmentState {
module: &shader, module: &shader,
entry_point: Some("fs_main"), entry_point: Some("fs_main"),
targets: &[Some(ColorTargetState { targets: &[Some(ColorTargetState {
format: target_format, format: config.format,
blend: Some(BlendState::ALPHA_BLENDING), blend: Some(BlendState::ALPHA_BLENDING),
write_mask: ColorWrites::ALL, write_mask: ColorWrites::ALL,
})], })],
@@ -422,26 +219,13 @@ impl UiRenderNode {
mask: !0, mask: !0,
alpha_to_coverage_enabled: false, alpha_to_coverage_enabled: false,
}, },
multiview_mask: None, multiview: None,
cache: None, cache: None,
}); });
// Reverse of the push order above. Only one of these should ever be Self {
// `Some` in practice -- three separate scopes exist to name *which*
// kind of error it was, not because more than one is expected at
// once.
let internal_err = internal_scope.pop().block_on();
let validation_err = validation_scope.pop().block_on();
let oom_err = oom_scope.pop().block_on();
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
return Err(err.to_string());
}
Ok(Self {
uniform_group, uniform_group,
primitive_layout, primitive_layout,
primitives,
primitive_group,
rsc_layout, rsc_layout,
rsc_group, rsc_group,
pipeline, pipeline,
@@ -449,13 +233,8 @@ impl UiRenderNode {
layers: HashMap::default(), layers: HashMap::default(),
active: Vec::new(), active: Vec::new(),
textures: tex_manager, textures: tex_manager,
instances,
masks, masks,
move_offsets, }
paints,
masks_layout,
masks_group,
})
} }
fn bind_group_0( fn bind_group_0(
@@ -488,40 +267,33 @@ impl UiRenderNode {
}) })
} }
/// Group 2: the shared atlas array and one standalone-image slot (a null fn rsc_layout(device: &Device, limits: &UiLimits) -> BindGroupLayout {
/// view for the main draw, a real one for each image's own bind group --
/// see `GpuTextures`), plus one sampler. No `count` on any entry: this
/// needs nothing beyond plain Vulkan 1.0 / GLES sampling, unlike the
/// `binding_array` layout it replaced (see TEXTURES.md's "Recommended
/// shape"). Masks and move_offsets are deliberately *not* here -- see
/// `masks_layout` below for why they get their own group.
fn rsc_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor { device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[ entries: &[
BindGroupLayoutEntry { BindGroupLayoutEntry {
binding: 0, binding: 0,
visibility: ShaderStages::FRAGMENT, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture { ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false }, sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2, view_dimension: TextureViewDimension::D2,
multisampled: false, multisampled: false,
}, },
count: None, count: Some(NonZero::new(limits.max_textures).unwrap()),
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: Some(NonZero::new(limits.max_samplers).unwrap()),
}, },
BindGroupLayoutEntry { BindGroupLayoutEntry {
binding: 2, binding: 2,
visibility: ShaderStages::FRAGMENT, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering), ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None, count: None,
}, },
], ],
@@ -529,134 +301,56 @@ impl UiRenderNode {
}) })
} }
/// The main group: rects and glyphs never sample the image slot, so it
/// gets a 1x1 null view rather than any live standalone image's.
fn rsc_group( fn rsc_group(
device: &Device, device: &Device,
layout: &BindGroupLayout, layout: &BindGroupLayout,
tex_manager: &GpuTextures, tex_manager: &GpuTextures,
masks: &ArrBuf<Mask>,
) -> BindGroup { ) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor { device.create_bind_group(&BindGroupDescriptor {
layout, layout,
entries: &[ entries: &[
BindGroupEntry { BindGroupEntry {
binding: 0, binding: 0,
resource: BindingResource::TextureView(tex_manager.array_view()), resource: BindingResource::TextureViewArray(&tex_manager.views()),
}, },
BindGroupEntry { BindGroupEntry {
binding: 1, binding: 1,
resource: BindingResource::TextureView(tex_manager.null_view()), resource: BindingResource::SamplerArray(&tex_manager.samplers()),
}, },
BindGroupEntry { BindGroupEntry {
binding: 2, binding: 2,
resource: BindingResource::Sampler(tex_manager.sampler()), resource: masks.buffer.as_entire_binding(),
}, },
], ],
label: Some("ui rsc"), label: Some("ui rsc"),
}) })
} }
fn masks_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 3,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
label: Some("ui masks"),
})
}
fn masks_group(
device: &Device,
layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
instances: &ArrBuf<PrimitiveInstance>,
paints: &ArrBuf<crate::LinearRgba>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 1,
resource: move_offsets.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: instances.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 3,
resource: paints.buffer.as_entire_binding(),
},
],
label: Some("ui masks"),
})
}
pub fn view_count(&self) -> usize { pub fn view_count(&self) -> usize {
self.textures.view_count() self.textures.view_count()
} }
}
pub fn take_image_bind_group_creates(&mut self) -> u64 { pub struct UiLimits {
self.textures.take_bind_group_creates() max_textures: u32,
max_samplers: u32,
}
impl Default for UiLimits {
fn default() -> Self {
Self {
max_textures: 100000,
max_samplers: 1000,
} }
pub fn take_atlas_pages_grown(&mut self) -> u64 {
self.textures.take_pages_grown()
} }
} }
/// What `UiRenderNode::update` changed this frame that a caller building a impl UiLimits {
/// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/ pub fn max_binding_array_elements_per_shader_stage(&self) -> u32 {
/// `take_atlas_pages_grown` for the two counters this doesn't carry (they self.max_textures + self.max_samplers
/// use the existing "call before update()" convention instead, so as not }
/// to disturb `bench_images`' documented counts). pub fn max_binding_array_sampler_elements_per_shader_stage(&self) -> u32 {
#[derive(Clone, Copy, Debug, Default)] self.max_samplers
pub struct FrameUpdateStats { }
pub masks_resized: bool,
pub moves_resized: bool,
pub paints_resized: bool,
} }
+88 -526
View File
@@ -1,27 +1,38 @@
use std::ops::Deref; use std::ops::{Deref, DerefMut};
use crate::{ use crate::{
UiRegion, WidgetId, Color, UiRegion, WidgetId,
render::{ render::{
ArrBuf, ArrBuf,
data::{MaskIdx, MoveIdx, PrimitiveInstance}, data::{MaskIdx, PrimitiveInstance},
}, },
util::{Dirty, HashSet},
}; };
use bytemuck::Pod; use bytemuck::Pod;
use wgpu::*; use wgpu::*;
/// The `binding` tag `Painter` writes on an image instance. Distinct from any pub struct Primitives {
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key instances: Vec<PrimitiveInstance>,
/// one from -- a bind group already selects the texture -- so this only ever assoc: Vec<WidgetId>,
/// has to match the shader's `TEXTURE` constant and flag "this instance is data: PrimitiveData,
/// drawn with its own bind group" to the code below. free: Vec<usize>,
pub const IMAGE_BINDING: u32 = 1; pub updated: bool,
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
data: Default::default(),
free: Vec::new(),
updated: true,
}
}
}
pub trait Primitive: Pod { pub trait Primitive: Pod {
const BINDING: u32; const BINDING: u32;
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>; fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
} }
macro_rules! primitives { macro_rules! primitives {
@@ -36,31 +47,13 @@ macro_rules! primitives {
} }
impl PrimitiveBuffers { impl PrimitiveBuffers {
/// Answers whether **any** of the per-primitive buffers was pub fn update(&mut self, device: &Device, queue: &Queue, data: &PrimitiveData) {
/// reallocated, which is the only thing that obliges the $(self.$name.update(device, queue, &data.$name);)*
/// caller to rebuild the bind group naming them. It used to
/// return nothing and the group was rebuilt on every frame
/// the arena changed -- once `ArrBuf` kept its buffer across
/// a length change, that was a bind group per frame for a
/// buffer identity that had not moved.
pub fn update(
&mut self,
device: &Device,
queue: &Queue,
data: &mut PrimitiveData,
) -> bool {
let mut reallocated = false;
$(
let (entries, dirty) = data.$name.for_upload();
reallocated |= self.$name.update(device, queue, entries, dirty);
)*
reallocated
} }
} }
impl PrimitiveBuffers { impl PrimitiveBuffers {
pub const LEN: usize = primitives!(@count $($name)*); pub const LEN: usize = primitives!(@count $($name)*);
pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*];
pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] { pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] {
[ [
$((<$ty>::BINDING, &self.$name.buffer),)* $((<$ty>::BINDING, &self.$name.buffer),)*
@@ -78,9 +71,6 @@ macro_rules! primitives {
} }
impl PrimitiveData { impl PrimitiveData {
pub fn needs_upload(&self) -> bool {
$(!self.$name.dirty.is_clean() ||)* false
}
pub fn clear(&mut self) { pub fn clear(&mut self) {
$(self.$name.clear();)* $(self.$name.clear();)*
} }
@@ -100,269 +90,75 @@ macro_rules! primitives {
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> { fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
&mut data.$name &mut data.$name
} }
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self> {
&data.$name
}
} }
)* )*
}; };
// The recursion has to hand back the same shape it matches -- space (@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t),+) };
// separated, not comma separated. Written with `$($t),+` it re-entered
// with a comma as the first token and never terminated, which happened to
// work only because there were exactly two primitives: the first step left
// a single token, and a single token matches the base case whichever
// separator it was written with.
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t)+) };
(@count $t:tt) => { 1 }; (@count $t:tt) => { 1 };
} }
pub struct Primitives { pub struct PrimitiveInst<P> {
instances: Vec<PrimitiveInstance>, pub id: WidgetId,
original_instances: Vec<Option<PrimitiveInstance>>, pub primitive: P,
assoc: Vec<WidgetId>, pub region: UiRegion,
handle_idx: Vec<u32>, pub mask_idx: MaskIdx,
freed: Vec<usize>,
reusable: Vec<usize>,
data: PrimitiveData,
pub dirty: Dirty,
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
original_instances: Default::default(),
assoc: Default::default(),
handle_idx: Default::default(),
freed: Vec::new(),
reusable: Vec::new(),
data: Default::default(),
dirty: Dirty::new_all(),
}
}
} }
impl Primitives { impl Primitives {
const NO_HANDLE: u32 = u32::MAX; pub fn write<P: Primitive>(
/// Writes a primitive into the arena and hands back its slot and its
/// entry in the per-primitive data. The caller (`UiRenderState`) puts
/// the slot into a layer's draw order -- an instance that no layer
/// names is never rasterized, which is what a mask shape drawn only to
/// be *referenced* uses.
pub fn alloc<P: Primitive>(
&mut self, &mut self,
layer: usize,
PrimitiveInst { PrimitiveInst {
id, id,
primitive, primitive,
region, region,
mask_idx, mask_idx,
move_idx,
}: PrimitiveInst<P>, }: PrimitiveInst<P>,
) -> (u32, usize) { ) -> PrimitiveHandle {
let data_idx = P::vec(&mut self.data).add(primitive); self.updated = true;
let slot = self.push( let vec = P::vec(&mut self.data);
PrimitiveInstance { let i = vec.add(primitive);
let inst = PrimitiveInstance {
region, region,
idx: data_idx as u32, idx: i as u32,
mask_idx, mask_idx,
move_idx,
binding: P::BINDING, binding: P::BINDING,
}, };
id, let inst_i = if let Some(i) = self.free.pop() {
);
(slot, data_idx)
}
pub fn alloc_image(
&mut self,
id: WidgetId,
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> u32 {
self.push(
PrimitiveInstance {
region,
idx: texture_idx,
mask_idx,
move_idx,
binding: IMAGE_BINDING,
},
id,
)
}
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
let slot = if let Some(i) = self.reusable.pop() {
self.instances[i] = inst; self.instances[i] = inst;
self.original_instances[i] = None;
self.assoc[i] = id; self.assoc[i] = id;
self.handle_idx[i] = Self::NO_HANDLE;
i i
} else { } else {
let i = self.instances.len();
self.instances.push(inst); self.instances.push(inst);
self.original_instances.push(None);
self.assoc.push(id); self.assoc.push(id);
self.handle_idx.push(Self::NO_HANDLE); i
self.instances.len() - 1
}; };
self.dirty.mark(slot); PrimitiveHandle::new::<P>(layer, inst_i, i)
slot as u32
} }
/// Rewrites a slot this widget already owns. Freed slots are not reusable /// returns (old index, new index)
/// until the end of a frame, so nested provisional redraws must recycle. pub fn apply_free(&mut self) -> impl Iterator<Item = PrimitiveChange> {
/// The caller has already checked that `h` is the same kind of self.free.sort_by(|a, b| b.cmp(a));
/// primitive in the same layer, which is what makes the slot, its self.free.drain(..).filter_map(|i| {
/// entry in the per-primitive data, and its position in the layer's self.instances.swap_remove(i);
/// draw order all still the right ones -- so nothing here touches self.assoc.swap_remove(i);
/// `freed`, `reusable` or `LayerOrder`, and no renumbering follows. if i == self.instances.len() {
pub fn recycle<P: Primitive>( return None;
&mut self,
h: &PrimitiveHandle,
PrimitiveInst {
id,
primitive,
region,
mask_idx,
move_idx,
}: PrimitiveInst<P>,
) {
debug_assert_eq!(
h.binding,
P::BINDING,
"recycling slot {} as a different kind of primitive than it holds",
h.slot,
);
P::vec(&mut self.data).set(h.data_idx, primitive);
self.set_instance(
h.slot,
PrimitiveInstance {
region,
idx: h.data_idx as u32,
mask_idx,
move_idx,
binding: P::BINDING,
},
id,
);
} }
let id = self.assoc[i];
pub fn recycle_image( let old = self.instances.len();
&mut self, Some(PrimitiveChange { id, old, new: i })
h: &PrimitiveHandle,
id: WidgetId,
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) {
debug_assert_eq!(
h.binding, IMAGE_BINDING,
"recycling slot {} as an image when it holds a primitive",
h.slot,
);
self.set_instance(
h.slot,
PrimitiveInstance {
region,
idx: texture_idx,
mask_idx,
move_idx,
binding: IMAGE_BINDING,
},
id,
);
}
/// Writes an instance into a slot that already holds one, marking it
/// dirty only if it differs -- the same rule as
/// [`PrimitiveVec::set`], for the same reason. `assoc` is not part of
/// the comparison because it is never uploaded.
fn set_instance(&mut self, slot: u32, inst: PrimitiveInstance, id: WidgetId) {
let slot = slot as usize;
self.assoc[slot] = id;
if bytemuck::bytes_of(&self.instances[slot]) == bytemuck::bytes_of(&inst) {
return;
}
if !self.dirty.contains(slot) {
self.original_instances[slot] = Some(self.instances[slot]);
}
self.instances[slot] = inst;
if self.original_instances[slot]
.is_some_and(|original| bytemuck::bytes_of(&original) == bytemuck::bytes_of(&inst))
{
self.original_instances[slot] = None;
self.dirty.unmark(slot);
} else {
self.dirty.mark(slot);
}
}
/// Retires a slot, answering the mask it was drawn under so the caller
/// can drop that mask's ref. The slot itself only becomes reusable at
/// the next [`Self::apply_free`] -- see `freed`.
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
let slot = h.slot as usize;
if h.binding != IMAGE_BINDING {
self.data.free(h.binding, h.data_idx);
}
self.freed.push(slot);
self.instances[slot].mask_idx
}
pub fn release_freed(&mut self) {
self.reusable.append(&mut self.freed);
}
pub fn owner(&self, slot: u32) -> WidgetId {
self.assoc[slot as usize]
}
pub fn set_handle_index(&mut self, slot: u32, idx: u32) {
self.handle_idx[slot as usize] = idx;
}
/// Where `slot`'s handle sits in its owner's `ActiveData::primitives`
/// -- see [`Self::handle_idx`]. `None` only for a slot whose owner
/// never took the handle, which nothing in this crate does.
pub fn handle_index(&self, slot: u32) -> Option<usize> {
match self.handle_idx[slot as usize] {
Self::NO_HANDLE => None,
idx => Some(idx as usize),
}
}
pub fn clear(&mut self) {
self.dirty.mark_all();
self.instances.clear();
self.original_instances.clear();
self.assoc.clear();
self.handle_idx.clear();
self.freed.clear();
self.reusable.clear();
self.data.clear();
}
pub fn live_count(&self) -> usize {
self.instances.len() - self.freed.len() - self.reusable.len()
}
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
(0..self.instances.len())
.filter(move |i| !dead.contains(i))
.map(|i| {
(
i as u32,
self.assoc[i],
self.instances[i].binding == IMAGE_BINDING,
)
}) })
} }
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self.updated = true;
self.data.free(h.binding, h.data_idx);
self.free.push(h.inst_idx);
self.instances[h.inst_idx].mask_idx
}
pub fn data(&self) -> &PrimitiveData { pub fn data(&self) -> &PrimitiveData {
&self.data &self.data
} }
@@ -371,206 +167,55 @@ impl Primitives {
&self.instances &self.instances
} }
pub fn instances_for_upload(&mut self) -> (&[PrimitiveInstance], &mut Dirty) {
(&self.instances, &mut self.dirty)
}
/// The per-primitive data, mutably, for the one caller that uploads it
/// (`UiRenderNode::update`) and so has to clear its dirty sets.
pub fn data_mut(&mut self) -> &mut PrimitiveData {
&mut self.data
}
pub fn needs_upload(&self) -> bool {
!self.dirty.is_clean() || self.data.needs_upload()
}
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
&self.instances[slot as usize]
}
/// The per-primitive data behind `slot`, or `None` if that slot holds
/// a different kind of primitive -- the `binding` check is the same
/// one the shader's dispatch switch makes, and it is what stops a
/// caller reading a glyph's index into the rect table.
pub fn primitive_data<P: Primitive>(&self, slot: u32) -> Option<&P> {
let inst = self.instance(slot);
(inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize])
}
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion { pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
let slot = h.slot as usize;
// The caller receives unrestricted mutable access, so this path
// cannot tell whether a later write restored the prior bytes. Leave
// the entry dirty rather than letting a stale `set_instance` baseline
// cancel it.
self.original_instances[slot] = None;
self.dirty.mark(slot);
&mut self.instances[h.slot as usize].region
}
}
/// Both lists free with `swap_remove`, so a layer's draw order was already
/// undefined before this split: nothing here may assume one primitive
/// stays adjacent to another once anything in the layer has been freed.
#[derive(Default)]
pub struct LayerOrder {
order: Vec<u32>,
order_dirty: Dirty,
images_dirty: Dirty,
/// Standalone images, kept apart because each draws with its own bind
/// group rather than sharing the layer's one instanced draw -- see
/// `UiRenderNode::draw`.
images: Vec<u32>,
free: Vec<usize>,
image_free: Vec<usize>,
pub updated: bool,
}
impl LayerOrder {
pub fn push(&mut self, slot: u32, is_image: bool) -> usize {
self.updated = true; self.updated = true;
let (list, dirty) = if is_image { &mut self.instances[h.inst_idx].region
(&mut self.images, &mut self.images_dirty)
} else {
(&mut self.order, &mut self.order_dirty)
};
list.push(slot);
dirty.mark(list.len() - 1);
list.len() - 1
}
pub fn free(&mut self, pos: usize, is_image: bool) {
self.updated = true;
if is_image {
self.image_free.push(pos);
} else {
self.free.push(pos);
}
}
pub fn apply_free(&mut self) -> Vec<OrderChange> {
let mut changes = Vec::new();
self.apply_free_into(&mut changes);
changes
}
pub(crate) fn apply_free_into(&mut self, changes: &mut Vec<OrderChange>) {
Self::apply_free_list(
&mut self.free,
&mut self.order,
&mut self.order_dirty,
false,
changes,
);
Self::apply_free_list(
&mut self.image_free,
&mut self.images,
&mut self.images_dirty,
true,
changes,
);
}
pub fn order_for_upload(&mut self) -> (&[u32], &mut Dirty) {
(&self.order, &mut self.order_dirty)
}
pub fn images_for_upload(&mut self) -> (&[u32], &mut Dirty) {
(&self.images, &mut self.images_dirty)
}
fn apply_free_list(
free: &mut Vec<usize>,
list: &mut Vec<u32>,
dirty: &mut Dirty,
is_image: bool,
changes: &mut Vec<OrderChange>,
) {
free.sort_by(|a, b| b.cmp(a));
for pos in free.drain(..) {
list.swap_remove(pos);
if pos != list.len() {
dirty.mark(pos);
changes.push(OrderChange {
slot: list[pos],
is_image,
pos,
});
}
}
}
pub fn order(&self) -> &Vec<u32> {
&self.order
}
pub fn images(&self) -> &Vec<u32> {
&self.images
} }
} }
pub struct OrderChange { pub struct PrimitiveChange {
pub slot: u32, pub id: WidgetId,
pub is_image: bool, pub old: usize,
pub pos: usize, pub new: usize,
} }
/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is #[derive(Debug)]
/// a primitive written only to be *referenced* -- a mask's shape
/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and
/// freed exactly like any other; it is simply never rasterized.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Drawn {
Yes,
No,
}
pub const NOT_DRAWN: usize = usize::MAX;
#[derive(Clone, Copy, Debug)]
pub struct PrimitiveHandle { pub struct PrimitiveHandle {
pub layer: usize, pub layer: usize,
pub pos: usize, pub inst_idx: usize,
pub slot: u32,
pub data_idx: usize, pub data_idx: usize,
pub binding: u32, pub binding: u32,
} }
impl PrimitiveHandle { impl PrimitiveHandle {
pub fn is_image(&self) -> bool { fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self {
self.binding == IMAGE_BINDING Self {
layer,
inst_idx,
data_idx,
binding: P::BINDING,
}
} }
}
pub struct PrimitiveInst<P> {
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
} }
primitives!( primitives!(
rects: RectPrimitive => 0, rects: RectPrimitive => 0,
glyphs: GlyphPrimitive => 2, textures: TexturePrimitive => 1,
); );
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct RectPrimitive { pub struct RectPrimitive {
/// Index into the separate paint buffer. Geometry stays untouched when a pub color: Color<u8>,
/// theme replaces the value at this index.
pub paint: u32,
pub radius: f32, pub radius: f32,
pub thickness: f32, pub thickness: f32,
pub inner_radius: f32, pub inner_radius: f32,
} }
impl RectPrimitive { impl RectPrimitive {
pub fn color(paint: u32) -> Self { pub fn color(color: Color<u8>) -> Self {
Self { Self {
paint, color,
radius: 0.0, radius: 0.0,
thickness: 0.0, thickness: 0.0,
inner_radius: 0.0, inner_radius: 0.0,
@@ -580,41 +225,14 @@ impl RectPrimitive {
#[repr(C)] #[repr(C)]
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive { pub struct TexturePrimitive {
pub uv_min: [f32; 2], pub view_idx: u32,
pub uv_max: [f32; 2], pub sampler_idx: u32,
/// Layer of the shared atlas array texture this glyph's page occupies --
/// not a bind-group or view index, since a page never gets one of its
/// own. See TEXTURES.md's "Recommended shape".
pub layer: u32,
pub paint: u32,
pub flags: u32,
_pad: u32,
}
impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1;
pub fn new(uv_min: [f32; 2], uv_max: [f32; 2], layer: u32, paint: u32, flags: u32) -> Self {
Self {
uv_min,
uv_max,
layer,
paint,
flags,
_pad: 0,
}
}
} }
pub struct PrimitiveVec<T> { pub struct PrimitiveVec<T> {
vec: Vec<T>, vec: Vec<T>,
free: Vec<usize>, free: Vec<usize>,
/// Which entries have changed since the last upload. Every way to
/// write one goes through [`Self::add`] or [`Self::set`], which is
/// what keeps this in step -- there is deliberately no `DerefMut`,
/// because an entry written through one would never be uploaded.
pub dirty: Dirty,
} }
impl<T> PrimitiveVec<T> { impl<T> PrimitiveVec<T> {
@@ -622,46 +240,24 @@ impl<T> PrimitiveVec<T> {
Self { Self {
vec: Vec::new(), vec: Vec::new(),
free: Vec::new(), free: Vec::new(),
dirty: Dirty::new_all(),
} }
} }
pub fn add(&mut self, t: T) -> usize { pub fn add(&mut self, t: T) -> usize {
let i = match self.free.pop() { if let Some(i) = self.free.pop() {
Some(i) => {
self.vec[i] = t; self.vec[i] = t;
i i
} } else {
None => { let i = self.vec.len();
self.vec.push(t); self.vec.push(t);
self.vec.len() - 1
}
};
self.dirty.mark(i);
i i
} }
pub fn set(&mut self, i: usize, t: T)
where
T: Pod,
{
if bytemuck::bytes_of(&self.vec[i]) == bytemuck::bytes_of(&t) {
return;
}
self.vec[i] = t;
self.dirty.mark(i);
} }
pub fn free(&mut self, i: usize) { pub fn free(&mut self, i: usize) {
self.free.push(i); self.free.push(i);
} }
/// The entries and the dirty set together, so an uploader can read one
/// while clearing the other -- they are different fields, but a
/// caller reaching for both through `Deref` cannot say so.
pub fn for_upload(&mut self) -> (&[T], &mut Dirty) {
(&self.vec, &mut self.dirty)
}
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.free.clear(); self.free.clear();
self.vec.clear(); self.vec.clear();
self.dirty.mark_all();
} }
} }
@@ -679,42 +275,8 @@ impl<T> Deref for PrimitiveVec<T> {
} }
} }
#[cfg(test)] impl<T> DerefMut for PrimitiveVec<T> {
mod tests { fn deref_mut(&mut self) -> &mut Self::Target {
use super::*; &mut self.vec
use crate::{UiVec2, Widgets, util::Id};
#[test]
fn an_instance_restored_before_upload_is_clean() {
let mut primitives = Primitives::default();
let mut widgets = Widgets::new();
let widget = widgets.add_strong(());
let owner = widget.id();
let original = PrimitiveInstance {
region: UiRegion::FULL,
binding: RectPrimitive::BINDING,
idx: 0,
mask_idx: MaskIdx::NONE,
move_idx: Id::preset(0),
};
primitives.push(original, owner);
primitives.dirty.clear();
let mut moved = original;
moved.region = moved.region.offset(UiVec2::abs((0.0, 20.0)));
primitives.set_instance(0, moved, owner);
assert!(!primitives.dirty.is_clean());
primitives.set_instance(0, original, owner);
assert!(
primitives.dirty.is_clean(),
"the GPU never observes the provisional position"
);
primitives.set_instance(0, moved, owner);
assert!(!primitives.dirty.is_clean());
primitives.dirty.clear();
primitives.set_instance(0, original, owner);
assert!(!primitives.dirty.is_clean());
} }
} }
-27
View File
@@ -1,27 +0,0 @@
use crate::util::Vec2;
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
let p = pos - center;
let q = Vec2::new(
p.x.abs() - (corner.x - radius),
p.y.abs() - (corner.y - radius),
);
let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0));
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
}
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
let edge: f32 = 0.5;
let corner = (bot_right - top_left) / 2.0;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
1.0 - smoothstep(-edge.min(radius), edge, dist)
}
/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL
/// when `low == high`, which is why the caller above never passes a zero
/// radius into the low edge without `edge` bounding it.
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
+60 -153
View File
@@ -1,41 +1,28 @@
const RECT: u32 = 0u; const RECT: u32 = 0u;
// Standalone images select their texture through their own bind group.
const TEXTURE: u32 = 1u; const TEXTURE: u32 = 1u;
const GLYPH: u32 = 2u;
@group(0) @binding(0) @group(0) @binding(0)
var<uniform> window: WindowUniform; var<uniform> window: WindowUniform;
@group(1) @binding(RECT) @group(1) @binding(RECT)
var<storage> rects: array<Rect>; var<storage> rects: array<Rect>;
@group(1) @binding(GLYPH) @group(1) @binding(TEXTURE)
var<storage> glyphs: array<GlyphInfo>; var<storage> textures: array<TextureInfo>;
struct Rect { struct Rect {
paint: u32, color: u32,
radius: f32, radius: f32,
thickness: f32, thickness: f32,
inner_radius: f32, inner_radius: f32,
} }
struct GlyphInfo { struct TextureInfo {
uv_min: vec2<f32>, view_idx: u32,
uv_max: vec2<f32>, sampler_idx: u32,
// A layer in the shared atlas array, not a bind-group index.
layer: u32,
paint: u32,
flags: u32,
} }
/// Mirrors `Mask` in data.rs. `parent` is u32::MAX at the root.
struct Mask { struct Mask {
primitive: u32, x: UiSpan,
parent: u32, y: UiSpan,
}
/// Mirrors `MoveOffset` in data.rs.
struct MoveOffset {
delta: vec2<f32>,
parent: u32,
} }
struct UiSpan { struct UiSpan {
@@ -48,72 +35,39 @@ struct UiScalar {
abs: f32, abs: f32,
} }
// One array texture avoids descriptor indexing, which is not universal on Android. struct UiVec2 {
@group(2) @binding(0) rel: vec2<f32>,
var atlas: texture_2d_array<f32>; abs: vec2<f32>,
// Image draws bind their texture here; other draws bind a 1x1 placeholder.
@group(2) @binding(1)
var image_texture: texture_2d<f32>;
@group(2) @binding(2)
var samp: sampler;
// Kept outside group 2 so standalone image bind groups need not name these buffers.
@group(3) @binding(0)
var<storage> masks: array<Mask>;
@group(3) @binding(1)
var<storage> move_offsets: array<MoveOffset>;
// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape.
@group(3) @binding(2)
var<storage> instances: array<PrimitiveInstance>;
// Solid linear RGBA today. Primitives already refer to paint records rather
// than embedding colours so gradients and texture fills can extend this
// lookup without rewriting geometry.
@group(3) @binding(3)
var<storage> paints: array<vec4<f32>>;
// Keep synchronized with render_state.rs. The bound prevents a malformed
// parent cycle from hanging the GPU; real widget trees have exceeded 16.
const PARENT_CHAIN_LIMIT: u32 = 64u;
fn resolve_move(idx: u32) -> vec2<f32> {
var total = vec2<f32>(0.0, 0.0);
var i = idx;
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
let entry = move_offsets[i];
total += entry.delta;
if entry.parent == 4294967295u {
break;
}
i = entry.parent;
}
return total;
} }
@group(2) @binding(0)
var views: binding_array<texture_2d<f32>>;
@group(2) @binding(1)
var samplers: binding_array<sampler>;
@group(2) @binding(2)
var<storage> masks: array<Mask>;
struct WindowUniform { struct WindowUniform {
dim: vec2<f32>, dim: vec2<f32>,
}; };
/// Mirrors `PrimitiveInstance` in data.rs.
struct PrimitiveInstance {
x: UiSpan,
y: UiSpan,
binding: u32,
idx: u32,
mask_idx: u32,
move_idx: u32,
}
struct InstanceInput { struct InstanceInput {
@location(0) slot: u32, @location(0) x_start: vec2<f32>,
@location(1) x_end: vec2<f32>,
@location(2) y_start: vec2<f32>,
@location(3) y_end: vec2<f32>,
@location(4) binding: u32,
@location(5) idx: u32,
@location(6) mask_idx: u32,
} }
struct VertexOutput { struct VertexOutput {
@location(0) top_left: vec2<f32>, @location(0) top_left: vec2<f32>,
@location(1) bot_right: vec2<f32>, @location(1) bot_right: vec2<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
// Naga requires integer varyings to declare flat interpolation. @location(3) binding: u32,
@location(3) @interpolate(flat) binding: u32, @location(4) idx: u32,
@location(4) @interpolate(flat) idx: u32, @location(5) mask_idx: u32,
@location(5) @interpolate(flat) mask_idx: u32,
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_position: vec4<f32>,
}; };
@@ -124,35 +78,20 @@ struct Region {
bot_right: vec2<f32>, bot_right: vec2<f32>,
} }
/// Shared by drawing and mask coverage so their geometry cannot diverge.
struct Corners {
top_left: vec2<f32>,
bot_right: vec2<f32>,
}
fn corners_of(inst: PrimitiveInstance) -> Corners {
let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel);
let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs);
let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel);
let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs);
let move_delta = resolve_move(inst.move_idx);
return Corners(
floor(top_left_rel * window.dim) + floor(top_left_abs + move_delta),
floor(bot_right_rel * window.dim) + floor(bot_right_abs + move_delta),
);
}
@vertex @vertex
fn vs_main( fn vs_main(
@builtin(vertex_index) vi: u32, @builtin(vertex_index) vi: u32,
in: InstanceInput, in: InstanceInput,
) -> VertexOutput { ) -> VertexOutput {
var out: VertexOutput; var out: VertexOutput;
let inst = instances[in.slot];
let c = corners_of(inst); let top_left_rel = vec2(in.x_start.x, in.y_start.x);
let top_left = c.top_left; let top_left_abs = vec2(in.x_start.y, in.y_start.y);
let bot_right = c.bot_right; let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs);
let size = bot_right - top_left; let size = bot_right - top_left;
let uv = vec2<f32>( let uv = vec2<f32>(
@@ -162,11 +101,11 @@ fn vs_main(
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0; let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0); out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
out.uv = uv; out.uv = uv;
out.binding = inst.binding; out.binding = in.binding;
out.idx = inst.idx; out.idx = in.idx;
out.top_left = top_left; out.top_left = top_left;
out.bot_right = bot_right; out.bot_right = bot_right;
out.mask_idx = inst.mask_idx; out.mask_idx = in.mask_idx;
return out; return out;
} }
@@ -184,79 +123,44 @@ fn fs_main(
color = draw_rounded_rect(region, rects[i]); color = draw_rounded_rect(region, rects[i]);
} }
case TEXTURE: { case TEXTURE: {
color = draw_texture(region); color = draw_texture(region, textures[i]);
}
case GLYPH: {
color = draw_glyph(region, glyphs[i]);
} }
default: { default: {
color = vec4(1.0, 0.0, 1.0, 1.0); color = vec4(1.0, 0.0, 1.0, 1.0);
} }
} }
// Nested masks multiply coverage, matching the CPU hit test. if in.mask_idx != 4294967295u {
var mask_idx = in.mask_idx; let mask = masks[in.mask_idx];
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) { let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
if mask_idx == 4294967295u { let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
break;
let top_left = floor(tl.rel * window.dim) + floor(tl.abs);
let bot_right = floor(br.rel * window.dim) + floor(br.abs);
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
color *= 0.0;
} }
let mask = masks[mask_idx];
color.a *= mask_coverage(pos, mask);
mask_idx = mask.parent;
} }
return color; return color;
} }
/// Uses the referenced primitive itself so its drawn and clipped edges agree. // TODO: this seems really inefficient (per frag indexing)?
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 { fn draw_texture(region: Region, info: TextureInfo) -> vec4<f32> {
let inst = instances[mask.primitive]; return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv);
if inst.binding != RECT {
// Painter::set_mask rejects non-rect shapes; fail open if that invariant breaks.
return 1.0;
}
let c = corners_of(inst);
return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius);
}
fn draw_texture(region: Region) -> vec4<f32> {
return textureSample(image_texture, samp, region.uv);
}
fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
let uv = mix(g.uv_min, g.uv_max, region.uv);
let texel = textureSample(atlas, samp, uv, i32(g.layer));
if (g.flags & 1u) != 0u {
return texel;
}
var color = paints[g.paint];
color.a *= texel.a;
return color;
}
/// Keep synchronized with the CPU hit-test implementation in render::sdf.
fn rounded_rect_coverage(
pos: vec2<f32>,
top_left: vec2<f32>,
bot_right: vec2<f32>,
radius: f32,
) -> f32 {
let edge = 0.5;
let corner = (bot_right - top_left) / 2.0;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
return 1.0 - smoothstep(-min(edge, radius), edge, dist);
} }
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> { fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
var color = paints[rect.paint]; var color = unpack4x8unorm(rect.color);
let edge = 0.5; let edge = 0.5;
color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius);
if rect.thickness > 0.0 {
let size = region.bot_right - region.top_left; let size = region.bot_right - region.top_left;
let corner = size / 2.0; let corner = size / 2.0;
let center = region.top_left + corner; let center = region.top_left + corner;
let dist = distance_from_rect(region.pos, center, corner, rect.radius);
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
if rect.thickness > 0.0 {
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius); let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2); color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
} }
@@ -265,7 +169,10 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
} }
fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, radius: f32) -> f32 { fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, radius: f32) -> f32 {
// vec from center to pixel
let p = pixel_pos - rect_center; let p = pixel_pos - rect_center;
// vec from inner rect corner to pixel
let q = abs(p) - (rect_corner - radius); let q = abs(p) - (rect_corner - radius);
return length(max(q, vec2(0.0))) - radius; return length(max(q, vec2(0.0))) - radius;
} }
+53 -375
View File
@@ -1,270 +1,59 @@
use image::{DynamicImage, EncodableLayout, GenericImageView}; use image::{DynamicImage, EncodableLayout};
use wgpu::{util::DeviceExt, *}; use wgpu::{util::DeviceExt, *};
use crate::{PatchRect, TextureKind, TextureUpdate, Textures}; use crate::{TextureUpdate, Textures};
use super::atlas::PAGE;
/// The fewest layers the glyph atlas array is ever created with. Two, not
/// one, for the GLES reason written on `create_array_texture`.
const MIN_ARRAY_LAYERS: u32 = 2;
enum Slot {
Empty,
Image(ImageGpu),
/// The array layer a live page occupies. Dropping the page empties this
/// resource slot; its array layer may then be assigned to a new page.
Page(u32),
}
struct ImageGpu {
#[allow(dead_code)]
texture: Texture,
view: TextureView,
bind_group: BindGroup,
}
/// - **The glyph atlas**, one `texture_2d_array` whose layers are pages
/// (`Slot::Page`), grown by recreating the array with headroom and
/// `copy_texture_to_texture`-ing the old layers across. No feature beyond
/// Vulkan 1.0/GLES sampling is needed for this -- a layer index is an
/// ordinary sampling operand.
/// - **Standalone images** (`Slot::Image`), each its own `Texture` and
/// `BindGroup`, drawn one `draw()` call at a time with that bind group
/// bound -- see `UiRenderNode::draw`.
pub struct GpuTextures { pub struct GpuTextures {
device: Device, device: Device,
queue: Queue, queue: Queue,
views: Vec<TextureView>,
slots: Vec<Slot>, view_count: usize,
samplers: Vec<Sampler>,
array_texture: Texture,
array_view: TextureView,
array_capacity: u32,
/// One past the highest layer ever populated. Holes below this remain in
/// place when the array grows, while `Textures` can reuse their numbers.
layer_high_water: u32,
sampler: Sampler,
/// Bound in the image slot of the main draw's bind group, which has
/// nothing of its own to put there: rects and glyphs never sample it,
/// but the layout requires something bound regardless.
null_view: TextureView, null_view: TextureView,
no_views: Vec<TextureView>,
bind_group_creates: u64,
pages_grown: u64,
} }
impl GpuTextures { impl GpuTextures {
/// Applies queued `Textures` updates, then reports whether the *main* pub fn update(&mut self, textures: &mut Textures) -> bool {
/// bind group (the one rects and glyphs draw with) needs rebuilding -- let mut changed = false;
/// true exactly when the atlas array was recreated (its view identity
/// changed). Pushing or freeing a standalone image never touches that
/// group: it built or drops its own. Masks/move_offsets resizing is
/// `UiRenderNode`'s own concern now (its `masks_group`, group 3) --
/// see that struct's field comment for why standalone images no longer
/// hear about either buffer at all.
pub fn update(&mut self, textures: &mut Textures, rsc_layout: &BindGroupLayout) -> bool {
let mut rebuild_main = false;
for update in textures.updates() { for update in textures.updates() {
changed = true;
match update { match update {
TextureUpdate::Push(kind, image) => { TextureUpdate::Push(image) => self.push(image),
rebuild_main |= self.push(kind, image, rsc_layout); TextureUpdate::Set(i, image) => self.set(i, image),
} TextureUpdate::SetFree => self.view_count += 1,
TextureUpdate::Set(kind, i, image) => {
rebuild_main |= self.set(kind, i, image, rsc_layout);
}
// A patch changes texture contents, not which layer or bind
// group exists, so it never asks for a rebuild -- rebuilding
// per glyph is exactly the cost this exists to avoid.
TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image),
TextureUpdate::SetFree => {}
TextureUpdate::Free(i) => self.free(i), TextureUpdate::Free(i) => self.free(i),
TextureUpdate::PushFree(_kind) => self.slots.push(Slot::Empty), TextureUpdate::PushFree => self.push_free(),
} }
} }
rebuild_main changed
} }
fn set(&mut self, i: u32, image: &DynamicImage) {
fn push( self.view_count += 1;
&mut self, let view = self.create_view(image);
kind: TextureKind, self.views[i as usize] = view;
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots.push(slot);
rebuilt
} }
fn set(
&mut self,
kind: TextureKind,
i: u32,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots[i as usize] = slot;
rebuilt
}
fn make_slot(
&mut self,
kind: TextureKind,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> (Slot, bool) {
match kind {
TextureKind::Image => {
let gpu = self.create_image(image, rsc_layout);
(Slot::Image(gpu), false)
}
TextureKind::Page { layer } => {
let mut rebuilt = false;
if layer >= self.array_capacity {
self.grow_array(rsc_layout);
rebuilt = true;
}
self.write_full_layer(layer, image);
self.layer_high_water = self.layer_high_water.max(layer + 1);
(Slot::Page(layer), rebuilt)
}
}
}
fn free(&mut self, i: u32) { fn free(&mut self, i: u32) {
if let Some(slot) = self.slots.get_mut(i as usize) { self.view_count -= 1;
*slot = Slot::Empty; self.views[i as usize] = self.null_view.clone();
} }
fn push(&mut self, image: &DynamicImage) {
self.view_count += 1;
let view = self.create_view(image);
self.views.push(view);
}
fn push_free(&mut self) {
self.view_count += 1;
self.views.push(self.null_view.clone());
} }
fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) { fn create_view(&self, image: &DynamicImage) -> TextureView {
let Some(&Slot::Page(layer)) = self.slots.get(i as usize) else { let image = image.to_rgba8();
return; let (width, height) = image.dimensions();
};
if rect.width == 0 || rect.height == 0 {
return;
}
let sub = image
.view(rect.x, rect.y, rect.width, rect.height)
.to_image();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: layer,
},
aspect: TextureAspect::All,
},
sub.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(rect.width * 4),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
}
fn write_full_layer(&self, layer: u32, image: &DynamicImage) {
// Every page is created as exactly PAGE x PAGE (`GlyphAtlas::allocate`),
// so this is always a whole-layer write, never a crop.
let rgba = image.to_rgba8();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: TextureAspect::All,
},
rgba.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(PAGE * 4),
rows_per_image: Some(PAGE),
},
Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: 1,
},
);
}
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
self.pages_grown += 1;
let new_capacity = self.array_capacity * 2;
let new_texture = Self::create_array_texture(&self.device, new_capacity);
if self.layer_high_water > 0 {
let mut encoder = self
.device
.create_command_encoder(&CommandEncoderDescriptor {
label: Some("atlas array grow"),
});
encoder.copy_texture_to_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
TexelCopyTextureInfo {
texture: &new_texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: self.layer_high_water,
},
);
self.queue.submit(std::iter::once(encoder.finish()));
}
self.array_texture = new_texture;
self.array_view = self.array_texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
self.array_capacity = new_capacity;
self.rebuild_image_bind_groups(rsc_layout);
}
fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) {
for slot in &mut self.slots {
if let Slot::Image(gpu) = slot {
gpu.bind_group = Self::make_image_bind_group(
&self.device,
rsc_layout,
&self.array_view,
&gpu.view,
&self.sampler,
);
self.bind_group_creates += 1;
}
}
}
fn create_image(&mut self, image: &DynamicImage, rsc_layout: &BindGroupLayout) -> ImageGpu {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let texture = self.device.create_texture_with_data( let texture = self.device.create_texture_with_data(
&self.queue, &self.queue,
&TextureDescriptor { &TextureDescriptor {
label: Some("image"), label: None,
size: Extent3d { size: Extent3d {
width, width,
height, height,
@@ -273,157 +62,46 @@ impl GpuTextures {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: TextureDimension::D2, dimension: TextureDimension::D2,
// `image` and swash colour-glyph bytes are encoded sRGB. format: TextureFormat::Rgba8Unorm,
// Sampling this view decodes RGB to the linear-light values usage: TextureUsages::TEXTURE_BINDING,
// used by the paint buffer and render pipeline; alpha stays
// linear.
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}, },
wgt::TextureDataOrder::MipMajor, wgt::TextureDataOrder::MipMajor,
rgba.as_bytes(), image.as_bytes(),
); );
let view = texture.create_view(&TextureViewDescriptor::default()); texture.create_view(&TextureViewDescriptor::default())
let bind_group = Self::make_image_bind_group(
&self.device,
rsc_layout,
&self.array_view,
&view,
&self.sampler,
);
self.bind_group_creates += 1;
ImageGpu {
texture,
view,
bind_group,
}
}
fn make_image_bind_group(
device: &Device,
rsc_layout: &BindGroupLayout,
array_view: &TextureView,
image_view: &TextureView,
sampler: &Sampler,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout: rsc_layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(array_view),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::TextureView(image_view),
},
BindGroupEntry {
binding: 2,
resource: BindingResource::Sampler(sampler),
},
],
label: Some("ui rsc image"),
})
}
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
debug_assert!(
capacity >= MIN_ARRAY_LAYERS,
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
);
device.create_texture(&TextureDescriptor {
label: Some("glyph atlas array"),
size: Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: capacity,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
// One array contains both alpha-mask glyphs and colour glyphs.
// sRGB decoding leaves mask pages' white RGB and alpha unchanged
// while correctly decoding colour-glyph RGB.
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_DST
| TextureUsages::COPY_SRC,
view_formats: &[],
})
} }
pub fn new(device: &Device, queue: &Queue) -> Self { pub fn new(device: &Device, queue: &Queue) -> Self {
let sampler = default_sampler(device);
let null_view = null_texture_view(device); let null_view = null_texture_view(device);
let array_capacity = MIN_ARRAY_LAYERS;
let array_texture = Self::create_array_texture(device, array_capacity);
let array_view = array_texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
Self { Self {
device: device.clone(), device: device.clone(),
queue: queue.clone(), queue: queue.clone(),
slots: Vec::new(), views: Vec::new(),
array_texture, samplers: vec![default_sampler(device)],
array_view, no_views: vec![null_view.clone()],
array_capacity,
layer_high_water: 0,
sampler,
null_view, null_view,
bind_group_creates: 0, view_count: 0,
pages_grown: 0,
} }
} }
pub fn take_bind_group_creates(&mut self) -> u64 { pub fn views(&self) -> Vec<&TextureView> {
std::mem::take(&mut self.bind_group_creates) if self.views.is_empty() {
&self.no_views
} else {
&self.views
}
.iter()
.by_ref()
.collect()
} }
pub fn take_pages_grown(&mut self) -> u64 { pub fn samplers(&self) -> Vec<&Sampler> {
std::mem::take(&mut self.pages_grown) self.samplers.iter().by_ref().collect()
}
pub fn array_view(&self) -> &TextureView {
&self.array_view
}
pub fn null_view(&self) -> &TextureView {
&self.null_view
}
pub fn sampler(&self) -> &Sampler {
&self.sampler
}
/// The bind group a standalone image draws with. Panics if `idx` names an
/// atlas page or a freed slot instead -- either is a caller bug (the
/// wrong kind of instance reached this draw path), not a condition to
/// recover from.
pub fn image_bind_group(&self, idx: u32) -> &BindGroup {
match self.slots.get(idx as usize) {
Some(Slot::Image(gpu)) => &gpu.bind_group,
other => panic!("texture slot {idx} is not a live standalone image: {other:?}"),
}
} }
pub fn view_count(&self) -> usize { pub fn view_count(&self) -> usize {
self.slots self.view_count
.iter()
.filter(|s| !matches!(s, Slot::Empty))
.count()
}
}
impl std::fmt::Debug for Slot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Slot::Empty => write!(f, "Empty"),
Slot::Image(_) => write!(f, "Image"),
Slot::Page(layer) => write!(f, "Page(layer={layer})"),
}
} }
} }
@@ -439,7 +117,7 @@ pub fn null_texture_view(device: &Device) -> TextureView {
mip_level_count: 1, mip_level_count: 1,
sample_count: 1, sample_count: 1,
dimension: TextureDimension::D2, dimension: TextureDimension::D2,
format: TextureFormat::Rgba8UnormSrgb, format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING, usage: TextureUsages::TEXTURE_BINDING,
view_formats: &[], view_formats: &[],
}) })
+11 -73
View File
@@ -1,63 +1,39 @@
use std::marker::PhantomData; use std::marker::PhantomData;
use crate::util::Dirty;
use bytemuck::Pod; use bytemuck::Pod;
use wgpu::*; use wgpu::*;
/// **The buffer has a capacity, and shrinking never reallocates.** That
/// is not only about allocation cost: a fresh `Buffer`'s contents are
/// undefined, so a reallocation is the one event after which a *partial*
/// upload is not correct. Keeping the buffer alive across a length change
/// is therefore the precondition for uploading only what changed, and
/// [`Self::update`] says which of the two happened so a caller can force
/// the whole range dirty.
pub struct ArrBuf<T: Pod> { pub struct ArrBuf<T: Pod> {
label: &'static str, label: &'static str,
usage: BufferUsages, usage: BufferUsages,
pub buffer: Buffer, pub buffer: Buffer,
len: usize, len: usize,
capacity: usize,
_pd: PhantomData<T>, _pd: PhantomData<T>,
} }
/// The smallest allocation worth making, in entries. A buffer that starts
/// at the exact first length reallocates on the second frame of anything;
/// this is small enough to be free and large enough that a handful of
/// masks or move offsets never grows at all.
const MIN_CAPACITY: usize = 64;
impl<T: Pod> ArrBuf<T> { impl<T: Pod> ArrBuf<T> {
pub fn new(device: &Device, usage: BufferUsages, label: &'static str) -> Self { pub fn new(device: &Device, usage: BufferUsages, label: &'static str) -> Self {
Self { Self {
label, label,
usage, usage,
buffer: Self::init_buf(device, MIN_CAPACITY, usage, label), buffer: Self::init_buf(device, 0, usage, label),
len: 0, len: 0,
capacity: MIN_CAPACITY,
_pd: PhantomData, _pd: PhantomData,
} }
} }
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) {
pub fn reserve(&mut self, device: &Device, len: usize) -> bool { if self.len != data.len() {
if len <= self.capacity { self.len = data.len();
return false; self.buffer =
Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label);
} }
let mut capacity = self.capacity.max(MIN_CAPACITY); queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
while capacity < len {
capacity *= 2;
} }
self.capacity = capacity; fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer {
self.buffer = Self::init_buf(device, capacity, self.usage, self.label); let mut size = size as u64;
true if usage.contains(BufferUsages::STORAGE) {
size = size.max(std::mem::size_of::<T>() as u64);
} }
fn init_buf(
device: &Device,
entries: usize,
usage: BufferUsages,
label: &'static str,
) -> Buffer {
let size = (entries.max(1) * std::mem::size_of::<T>()) as u64;
device.create_buffer(&BufferDescriptor { device.create_buffer(&BufferDescriptor {
label: Some(label), label: Some(label),
size, size,
@@ -65,44 +41,6 @@ impl<T: Pod> ArrBuf<T> {
usage, usage,
}) })
} }
/// Writes the entries `dirty` names and clears it, answering whether
/// the underlying `Buffer` was **recreated** -- which a caller holding
/// a `BindGroup` over it must know, since it has to rebuild that group.
///
/// Correct only because the buffer outlives the data in it: a
/// reallocation leaves the rest of the buffer undefined, which is why
/// one forces the whole range dirty here rather than leaving the
/// caller to remember. Measured over the bench fixture
/// (`scripts/rigs/ui-profile`'s `arena_churn`): a fling writes 3.3% of
/// what the whole-array path wrote, and the median frame writes
/// nothing at all.
pub fn update(
&mut self,
device: &Device,
queue: &Queue,
data: &[T],
dirty: &mut Dirty,
) -> bool {
let reallocated = self.reserve(device, data.len());
if reallocated {
dirty.mark_all();
}
self.len = data.len();
let stride = std::mem::size_of::<T>() as BufferAddress;
dirty.for_each_range(data.len(), Self::MERGE_GAP, |range| {
queue.write_buffer(
&self.buffer,
range.start as BufferAddress * stride,
bytemuck::cast_slice(&data[range]),
);
});
dirty.clear();
reallocated
}
const MERGE_GAP: usize = 1024 / std::mem::size_of::<T>();
#[allow(clippy::len_without_is_empty)] #[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.len self.len
-160
View File
@@ -1,160 +0,0 @@
use crate::{PixelRegion, UiRenderState, UiRsc, WidgetId, Widgets, util::HashMap};
use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate};
/// Reserved for the synthetic root; every real widget's `SlotId::as_u64`
/// starts at 1, so this can never collide with one (see that method's
/// doc comment).
const WINDOW_NODE: NodeId = NodeId(0);
fn node_id(id: WidgetId) -> NodeId {
NodeId(id.as_u64())
}
struct Entry {
name: String,
role: Role,
bounds: PixelRegion,
seen: u64,
}
fn entry_node(entry: &Entry) -> Node {
let mut node = Node::new(entry.role);
node.set_label(entry.name.clone());
node.set_bounds(Rect {
x0: entry.bounds.top_left.x as f64,
y0: entry.bounds.top_left.y as f64,
x1: entry.bounds.bot_right.x as f64,
y1: entry.bounds.bot_right.y as f64,
});
node
}
/// Owns the last tree pushed out, so `update` can tell "nothing
/// accessibility-relevant changed" from "something did" without asking
/// the platform adapter to diff two `Node`s itself. One of these per
/// window/view -- `desktop::DesktopUiState` and `android::AndroidUiState`
/// each keep one.
#[derive(Default)]
pub struct AccessTree {
known: HashMap<WidgetId, Entry>,
generation: u64,
rebuilds: u64,
}
impl AccessTree {
pub fn new() -> Self {
Self::default()
}
/// Refresh the retained accessibility state and report whether it changed.
/// Existing entries are updated in place so an ordinary frame allocates
/// nothing, including one where only bounds changed.
pub fn refresh(&mut self, widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> bool {
self.generation = self.generation.wrapping_add(1);
if self.generation == 0 {
self.known.clear();
self.generation = 1;
}
let generation = self.generation;
let mut changed = false;
for id in widgets.named() {
let Some(bounds) = render.window_region(&id, rsc) else {
continue;
};
let Some(widget) = widgets.get_dyn(id) else {
continue;
};
let name = widgets.label(id);
let role = widget.access_role();
match self.known.get_mut(&id) {
Some(entry) => {
if entry.name != *name {
entry.name.clone_from(name);
changed = true;
}
if entry.role != role || entry.bounds != bounds {
entry.role = role;
entry.bounds = bounds;
changed = true;
}
entry.seen = generation;
}
None => {
self.known.insert(
id,
Entry {
name: name.clone(),
role,
bounds,
seen: generation,
},
);
changed = true;
}
}
}
let old_len = self.known.len();
self.known.retain(|_, entry| entry.seen == generation);
changed |= self.known.len() != old_len;
if changed {
self.rebuilds += 1;
}
changed
}
/// Walks `widgets.named()`, looks up each one's current screen bounds
/// via `render.window_region` (which resolves the same move-chain
/// `resolved_region` does, so a moved subtree reports where it
/// actually is), and returns a full `TreeUpdate` if and only if that
/// set differs from the last call -- added, removed, renamed, or
/// moved/resized. A widget that is named but not currently active
/// (not drawn this frame) is left out, the same as one never named at
/// all.
pub fn update(
&mut self,
widgets: &Widgets,
render: &UiRenderState,
rsc: &dyn UiRsc,
) -> Option<TreeUpdate> {
if !self.refresh(widgets, render, rsc) {
return None;
}
Some(self.tree_update())
}
pub fn tree_update(&self) -> TreeUpdate {
build_update(&self.known)
}
/// The unconditional twin of `update`, for a platform adapter's
/// activation handler (`android/access.rs`'s `AndroidAccessSource`) --
/// AccessKit asks for a full tree the first time a client attaches,
/// which is exactly the case `update`'s diff-against-`known` is not
/// meant to answer (it may have already sent this same snapshot to a
/// client that has since detached and reattached).
pub fn build_full(widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> TreeUpdate {
let mut tree = Self::new();
tree.refresh(widgets, render, rsc);
tree.tree_update()
}
pub fn take_rebuilds(&mut self) -> u64 {
std::mem::take(&mut self.rebuilds)
}
}
fn build_update(current: &HashMap<WidgetId, Entry>) -> TreeUpdate {
let mut window = Node::new(Role::Window);
let mut nodes = Vec::with_capacity(current.len() + 1);
for (&id, entry) in current {
window.push_child(node_id(id));
nodes.push((node_id(id), entry_node(entry)));
}
nodes.push((WINDOW_NODE, window));
TreeUpdate {
nodes,
tree: Some(TreeInfo::new(WINDOW_NODE)),
tree_id: TreeId::ROOT,
focus: WINDOW_NODE,
}
}
+4 -19
View File
@@ -1,31 +1,16 @@
use crate::{ use crate::{Align, LayerId, MaskIdx, PrimitiveHandle, TextureHandle, UiRegion, WidgetId};
LayerId, MaskIdx, MoveIdx, PaintId, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId,
util::Vec2,
};
/// important non rendering data for retained drawing
#[derive(Debug)] #[derive(Debug)]
pub struct ActiveData { pub struct ActiveData {
pub id: WidgetId, pub id: WidgetId,
pub region: UiRegion, pub region: UiRegion,
pub region_used: UiRegion,
pub parent: Option<WidgetId>, pub parent: Option<WidgetId>,
pub textures: Vec<TextureHandle>, pub textures: Vec<TextureHandle>,
pub(crate) spare_textures: Vec<TextureHandle>,
/// Paint slots retained by this draw. The GPU primitive stores only the
/// slot index, so these handles are what prevent a live primitive from
/// observing a recycled paint.
pub paints: Vec<PaintId>,
pub(crate) spare_paints: Vec<PaintId>,
pub primitives: Vec<PrimitiveHandle>, pub primitives: Vec<PrimitiveHandle>,
pub(crate) spare_primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>, pub children: Vec<WidgetId>,
pub(crate) spare_children: Vec<WidgetId>,
pub size_dependencies: Vec<WidgetId>,
pub mask: MaskIdx, pub mask: MaskIdx,
/// The widget's retained mask slot, or `MaskIdx::NONE`.
pub own_mask: MaskIdx,
pub layer: LayerId, pub layer: LayerId,
pub size: Size, pub align: Align,
pub move_slot: MoveIdx,
pub child_move_slot: Option<MoveIdx>,
pub move_applied: Vec2,
} }
+209
View File
@@ -0,0 +1,209 @@
use crate::{
ActiveData, Align, Axis, EventsLike, Painter, Ui, UiRegion, WidgetId,
render::MaskIdx,
util::{HashSet, forget_ref},
};
use std::ops::{Deref, DerefMut};
/// state maintained between widgets during painting
pub struct DrawState<'a> {
pub(super) ui: &'a mut Ui,
pub(super) events: &'a mut dyn EventsLike,
draw_started: HashSet<WidgetId>,
}
impl<'a> DrawState<'a> {
pub fn new(ui: &'a mut Ui, events: &'a mut dyn EventsLike) -> Self {
Self {
ui,
events,
draw_started: Default::default(),
}
}
pub fn redraw_updates(&mut self) {
while let Some(&id) = self.widgets.needs_redraw.iter().next() {
self.redraw(id);
}
self.ui.free(self.events);
}
/// redraws a widget that's currently active (drawn)
pub fn redraw(&mut self, id: WidgetId) {
self.widgets.needs_redraw.remove(&id);
let Some(active) = self.active.get(&id) else {
return;
};
let ActiveData {
id,
region,
parent,
mask,
layer,
align,
..
} = *active;
self.draw_inner(layer, id, region, parent, mask, align);
}
pub fn redraw_all(&mut self) {
// free all resources & cache
for (_, active) in self.ui.active.drain() {
self.events.undraw(&active);
}
self.ui.free(self.events);
self.layers.clear();
self.widgets.needs_redraw.clear();
if let Some(id) = &self.ui.root {
self.draw_inner(0, id.id(), UiRegion::FULL, None, MaskIdx::NONE, Align::NONE);
}
}
pub(super) fn draw_inner(
&mut self,
layer: usize,
id: WidgetId,
region: UiRegion,
parent: Option<WidgetId>,
mask: MaskIdx,
align: Align,
) {
let mut old_children = Vec::new();
if let Some(active) = self.ui.active.get_mut(&id) {
// check to see if we can skip drawing first, and just need to move
if !self.ui.widgets.needs_redraw.contains(&id) {
if active.region == region {
return;
} else if active.region.size() == region.size() {
self.mov(id, active.region, region);
return;
}
}
let active = self.remove(id, false).unwrap();
old_children = active.children;
}
// draw widget
self.draw_started.insert(id);
let mut painter = Painter {
state: self,
region,
region_used: region,
mask,
layer,
id,
textures: Vec::new(),
primitives: Vec::new(),
children: Vec::new(),
};
let mut widget = painter.state.widgets.get_dyn(id);
widget.draw(&mut painter);
let walign = painter.state.widgets.data(id).unwrap().align;
let align = align.or(walign.into());
let Painter {
region_used,
state: _,
region,
mask,
textures,
primitives,
children,
layer,
id,
} = painter;
// remove old children that weren't kept
for c in &old_children {
if !children.contains(c) {
self.remove_rec(*c);
}
}
// add to active
self.active.insert(
id,
ActiveData {
id,
region,
parent,
textures,
primitives,
children,
region_used,
mask,
layer,
},
);
// TODO: unsure if there's a better way, currently using epsilon
// in PartialEq impl for UiScalar
let target = region_used.size().align(align);
if region_used != target {
self.mov(id, from, to);
}
self.events.draw(self.active.get(&id).unwrap());
}
fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion) {
let active = self.ui.active.get_mut(&id).unwrap();
for h in &active.primitives {
let region = self.ui.layers[h.layer].region_mut(h);
*region = region.outside(&from).within(&to);
}
active.region = active.region.outside(&from).within(&to);
// SAFETY: children cannot be recursive
let children = unsafe { forget_ref(&active.children) };
for child in children {
self.mov(*child, from, to);
}
}
/// NOTE: instance textures are cleared and self.textures freed
fn remove(&mut self, id: WidgetId, undraw: bool) -> Option<ActiveData> {
let mut active = self.active.remove(&id);
if let Some(active) = &mut active {
for h in &active.primitives {
let mask = self.layers.free(h);
if mask != MaskIdx::NONE {
self.masks.remove(mask);
}
}
active.textures.clear();
self.textures.free();
if undraw {
self.events.undraw(active);
}
}
active
}
fn remove_rec(&mut self, id: WidgetId) -> Option<ActiveData> {
let inst = self.remove(id, true);
if let Some(inst) = &inst {
for c in &inst.children {
self.remove_rec(*c);
}
}
inst
}
}
impl Deref for DrawState<'_> {
type Target = Ui;
fn deref(&self) -> &Self::Target {
self.ui
}
}
impl DerefMut for DrawState<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
self.ui
}
}
+177 -181
View File
@@ -1,215 +1,211 @@
use crate::{ use crate::{
Mask, MoveOffset, Paints, TextResources, Textures, WeakWidget, WidgetId, Widgets, EventsLike, IdLike, Mask, PixelRegion, PrimitiveLayers, TextData, TextureHandle, Textures,
util::TrackedArena, Widget, WidgetHandle, WidgetId, Widgets,
ui::draw_state::DrawState,
util::{HashMap, TrackedArena, Vec2},
}; };
use image::DynamicImage;
use std::{ use std::{
cell::{Ref, RefCell, RefMut}, ops::{Index, IndexMut},
ops::{Deref, DerefMut}, sync::mpsc::{Receiver, channel},
rc::Rc,
}; };
mod access;
mod active; mod active;
mod draw_state;
mod painter; mod painter;
mod render_state; mod state;
pub use access::*;
pub use active::*; pub use active::*;
pub use painter::{DrawResult, Painter}; pub use painter::Painter;
pub use render_state::*;
#[derive(Default)]
pub struct UiData {
pub widgets: Widgets,
pub paints: Paints,
pub textures: Textures,
pub text: Rc<RefCell<TextResources>>,
pub masks: TrackedArena<Mask, u32>,
pub move_offsets: TrackedArena<MoveOffset, u32>,
}
#[derive(Clone)]
pub struct RenderHandle {
pub(crate) render_state: Rc<RefCell<UiRenderState>>,
}
impl RenderHandle {
/// The retained result of the last completed frame. The framework holds
/// the corresponding mutable borrow for the whole of a render update, so
/// a read attempted while that state is incomplete fails at the boundary
/// instead of observing half a frame.
pub fn get(&self) -> Ref<'_, UiRenderState> {
self.render_state
.try_borrow()
.expect("render state cannot be read while a frame is being rendered")
}
pub(crate) fn get_mut(&self) -> RefMut<'_, UiRenderState> {
self.render_state
.try_borrow_mut()
.expect("render state cannot be mutated while it is being read")
}
}
impl Default for RenderHandle {
fn default() -> Self {
Self {
render_state: Rc::new(RefCell::new(UiRenderState::new())),
}
}
}
#[derive(Default)]
pub struct Ui { pub struct Ui {
data: UiData, // TODO: edit visibilities
pub(crate) render_state: RenderHandle, pub widgets: Widgets,
// retained painter state
pub active: HashMap<WidgetId, ActiveData>,
pub layers: PrimitiveLayers,
pub textures: Textures,
pub text: TextData,
output_size: Vec2,
pub masks: TrackedArena<Mask, u32>,
pub root: Option<WidgetHandle>,
old_root: Option<WidgetId>,
recv: Receiver<WidgetId>,
resized: bool,
}
pub trait HasUi: Sized {
fn get(&self) -> &Ui;
fn get_mut(&mut self) -> &mut Ui;
fn ui(&self) -> &Ui {
self.get()
}
fn ui_mut(&mut self) -> &mut Ui {
self.get_mut()
}
}
impl HasUi for Ui {
fn get(&self) -> &Ui {
self
}
fn get_mut(&mut self) -> &mut Ui {
self
}
} }
impl Ui { impl Ui {
/// Register application-owned font data for a semantic or named family. /// useful for debugging
/// Existing text resources are invalidated and their active widgets are pub fn set_label(&mut self, id: impl IdLike, label: String) {
/// scheduled for layout again. self.widgets.data_mut(id.id()).unwrap().label = label;
#[track_caller]
pub fn register_font(
&mut self,
family: impl AsRef<str>,
data: impl AsRef<[u8]> + Send + Sync + 'static,
) -> Result<(), crate::FontRegistrationError> {
self.update_fonts(|text| text.register_font(family, data))
} }
/// Register application-owned font data in a named glyph-atlas bucket. pub fn label(&self, id: impl IdLike) -> &String {
/// Fonts registered without this method share the default bucket. &self.widgets.data(id.id()).unwrap().label
#[track_caller]
pub fn register_font_in(
&mut self,
family: impl AsRef<str>,
bucket: impl AsRef<str>,
data: impl AsRef<[u8]> + Send + Sync + 'static,
) -> Result<(), crate::FontRegistrationError> {
self.update_fonts(|text| text.register_font_in(family, bucket, data))
} }
/// Replace an application's registered font while retaining its atlas pub fn new() -> Self {
/// bucket. Text is reshaped and the bucket's old glyph pages are released. Self::default()
#[track_caller]
pub fn replace_font(
&mut self,
family: impl AsRef<str>,
data: impl AsRef<[u8]> + Send + Sync + 'static,
) -> Result<(), crate::FontRegistrationError> {
self.update_fonts(|text| text.replace_font(family, data))
} }
/// Replace an application's registered font and assign the replacement to pub fn get<I: IdLike>(&self, id: &I) -> Option<&I::Widget>
/// a named glyph-atlas bucket.
#[track_caller]
pub fn replace_font_in(
&mut self,
family: impl AsRef<str>,
bucket: impl AsRef<str>,
data: impl AsRef<[u8]> + Send + Sync + 'static,
) -> Result<(), crate::FontRegistrationError> {
self.update_fonts(|text| text.replace_font_in(family, bucket, data))
}
fn update_fonts(
&mut self,
update: impl FnOnce(&mut TextResources) -> Result<(), crate::FontRegistrationError>,
) -> Result<(), crate::FontRegistrationError> {
let owners = {
let mut text = self.data.text.borrow_mut();
update(&mut text)?;
text.invalidate_all()
};
let mut active = owners;
{
let render = self.render_state.get();
active.retain(|owner| render.active.contains_key(owner));
}
self.data.widgets.needs_redraw.extend(active);
Ok(())
}
pub fn is_font_registered(&self, family: impl AsRef<str>) -> bool {
self.data.text.borrow().is_font_registered(family)
}
/// A read-only handle to the retained result of the last completed frame.
/// The handle is owned so a caller may keep its read guard while mutating
/// unrelated resources on the `Rsc` that owns this `Ui`.
pub fn render_state(&self) -> RenderHandle {
self.render_state.clone()
}
pub fn resize(&self, size: impl Into<crate::util::Vec2>) {
self.render_state.get_mut().resize(size);
}
pub fn set_density(&mut self, density: f32) {
self.data.text.borrow_mut().density = density;
self.render_state.get_mut().set_density(density);
}
}
impl Deref for Ui {
type Target = UiData;
fn deref(&self) -> &Self::Target {
&self.data
}
}
impl DerefMut for Ui {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.data
}
}
pub trait UiRsc {
fn ui(&self) -> &Ui;
fn ui_mut(&mut self) -> &mut Ui;
fn draw<'a>(&mut self, root: impl Into<Option<&'a crate::StrongWidget>>)
where where
Self: Sized, I::Widget: Sized + Widget,
{ {
self.draw_at(root, std::time::Instant::now()); self.widgets.get(id)
} }
fn draw_at<'a>( pub fn get_mut<I: IdLike>(&mut self, id: &I) -> Option<&mut I::Widget>
&mut self,
root: impl Into<Option<&'a crate::StrongWidget>>,
frame_time: std::time::Instant,
) -> bool
where where
Self: Sized, I::Widget: Sized + Widget,
{ {
let render_state = self.ui().render_state.clone(); self.widgets.get_mut(id)
render_state.get_mut().update_at(root, self, frame_time)
} }
#[allow(unused_variables)] pub fn add_texture(&mut self, image: DynamicImage) -> TextureHandle {
fn on_add(&mut self, id: WeakWidget) {} self.textures.add(image)
#[allow(unused_variables)] }
fn on_remove(&mut self, id: WidgetId) {}
#[allow(unused_variables)]
fn on_draw(&mut self, active: &ActiveData) {}
#[allow(unused_variables)]
fn on_undraw(&mut self, active: &ActiveData) {}
fn widgets(&self) -> &Widgets { pub fn resize(&mut self, size: impl Into<Vec2>) {
&self.ui().widgets self.output_size = size.into();
self.resized = true;
} }
fn widgets_mut(&mut self) -> &mut Widgets {
&mut self.ui_mut().widgets pub fn update(&mut self, events: &mut dyn EventsLike) {
if !self.widgets.waiting.is_empty() {
let len = self.widgets.waiting.len();
let all: Vec<_> = self
.widgets
.waiting
.iter()
.map(|&w| format!("'{}' ({w:?})", self.label(w)))
.collect();
panic!(
"{len} widget(s) were never upgraded\n\
this is likely a memory leak; consider upgrading to strong if you plan on using it later\n\
weak widgets: {all:#?}"
);
} }
fn free(&mut self) { if self.root_changed() {
while let Some(id) = self.widgets_mut().free_next() { DrawState::new(self, events).redraw_all();
self.on_remove(id); self.old_root = self.root.as_ref().map(|r| r.id());
} else if self.widgets.has_updates() {
DrawState::new(self, events).redraw_updates();
}
if self.resized {
self.resized = false;
DrawState::new(self, events).redraw_all();
}
}
/// free any resources that don't have references anymore
fn free(&mut self, events: &mut dyn EventsLike) {
for id in self.recv.try_iter() {
events.remove(id);
self.widgets.delete(id);
}
self.textures.free();
}
pub fn root_changed(&self) -> bool {
self.root.as_ref().map(|r| r.id()) != self.old_root
}
pub fn needs_redraw(&self) -> bool {
self.root_changed() || self.widgets.has_updates()
}
pub fn num_widgets(&self) -> usize {
self.widgets.len()
}
pub fn active_widgets(&self) -> usize {
self.active.len()
}
pub fn debug_layers(&self) {
for ((idx, depth), primitives) in self.layers.iter_depth() {
let indent = " ".repeat(depth * 2);
let len = primitives.instances().len();
print!("{indent}{idx}: {len} primitives");
if len >= 1 {
print!(" ({})", primitives.instances()[0].binding);
}
println!();
}
}
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let region = self.active.get(&id.id())?.region;
Some(region.to_px(self.output_size))
}
pub fn debug(&self, label: &str) -> impl Iterator<Item = &ActiveData> {
self.active.iter().filter_map(move |(&id, inst)| {
let l = self.widgets.label(id);
if l == label { Some(inst) } else { None }
})
}
}
impl<I: IdLike> Index<I> for Ui
where
I::Widget: Sized + Widget,
{
type Output = I::Widget;
fn index(&self, id: I) -> &Self::Output {
self.get(&id).unwrap()
}
}
impl<I: IdLike> IndexMut<I> for Ui
where
I::Widget: Sized + Widget,
{
fn index_mut(&mut self, id: I) -> &mut Self::Output {
self.get_mut(&id).unwrap()
}
}
impl Default for Ui {
fn default() -> Self {
let (send, recv) = channel();
Self {
widgets: Widgets::new(send),
active: Default::default(),
layers: Default::default(),
masks: Default::default(),
text: Default::default(),
textures: Default::default(),
output_size: Vec2::ZERO,
root: None,
old_root: None,
recv,
resized: false,
} }
self.ui_mut().text.borrow_mut().free_released();
self.ui_mut().textures.free();
self.ui_mut().paints.free_released();
} }
} }
+46 -475
View File
@@ -1,516 +1,93 @@
use crate::{ use crate::{
Axis, LayoutLen, MoveOffset, PaintId, RegionAlign, RenderedText, Size, StrongWidget, RenderedText, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion, WidgetHandle, WidgetId,
TextHandle, TextResources, TextureHandle, UiData, UiRegion, UiRenderState, UiRsc, UiScalar, render::{Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
UiVec2, WidgetId, ui::draw_state::DrawState,
render::{
Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive,
PrimitiveHandle, PrimitiveInst, RectPrimitive,
},
ui::render_state::Retained,
util::Vec2, util::Vec2,
}; };
use std::{cell::RefCell, rc::Rc, time::Instant};
pub struct Painter<'a> { /// makes your surfaces look pretty
pub(super) render_state: &'a mut UiRenderState, pub struct Painter<'a, 'b> {
pub(super) rsc: &'a mut dyn UiRsc, pub(super) state: &'a mut DrawState<'b>,
pub region_used: UiRegion,
pub(super) region: UiRegion, pub(super) region: UiRegion,
pub(super) mask: MaskIdx, pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx,
pub(super) child_move_slot: Option<MoveIdx>,
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>, pub(super) textures: Vec<TextureHandle>,
pub(super) recycle_textures: Vec<TextureHandle>,
pub(super) paints: Vec<PaintId>,
pub(super) recycle_paints: Vec<PaintId>,
pub(super) primitives: Vec<PrimitiveHandle>, pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) recycle: Vec<PrimitiveHandle>,
pub(super) recycle_at: usize,
pub(super) children: Vec<WidgetId>, pub(super) children: Vec<WidgetId>,
pub(super) size_dependencies: Vec<WidgetId>,
pub(super) size: Option<Size>,
/// Whether a retained child's length on each axis is still valid. A
/// child's length may change when the parent's orthogonal extent changes
/// (most importantly, wrapped text gets taller when it gets narrower),
/// but not merely because a content-sized parent grew along that same
/// axis around one of its siblings.
pub(super) reuse_child_sizes: [bool; 2],
pub layer: usize, pub layer: usize,
pub(super) id: WidgetId, pub(super) id: WidgetId,
} }
pub struct DrawResult<'p, 'a> { impl<'a, 'c> Painter<'a, 'c> {
painter: &'p mut Painter<'a>,
child: WidgetId,
}
impl DrawResult<'_, '_> {
pub fn size(self) -> Size {
if !self.painter.size_dependencies.contains(&self.child) {
self.painter.size_dependencies.push(self.child);
}
self.painter.render_state.active[&self.child].size
}
}
impl<'a> Painter<'a> {
/// The presentation time of this frame, supplied by the platform. On a
/// backend with a display clock this is the vsync timestamp, not the time
/// at which this widget happened to be drawn.
pub fn frame_time(&self) -> Instant {
self.render_state.frame_time
}
/// Redraw this widget on the following frame and ask the platform to
/// produce that frame. The invalidation is staged until the current
/// retained redraw has finished, so it cannot be consumed again in this
/// frame.
pub fn request_next_frame(&mut self) {
self.render_state.next_frame.insert(self.id);
}
pub fn set_size(&mut self, size: Size) {
assert!(
self.size.replace(size).is_none(),
"a widget set its size more than once during one draw"
);
}
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) { fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
self.write_primitive(primitive, region, Drawn::Yes); let h = self.state.layers.write(
} self.layer,
PrimitiveInst {
/// **Consumed strictly in order, and one mismatch ends recycling for
/// the rest of the draw.** A widget's `draw` is a function of its own
/// state, so a redraw writes the same sequence of primitives in the
/// same order in the overwhelmingly common case; searching the
/// remainder for a match would turn an O(1) step into an O(primitives)
/// one to rescue a case that means the widget's content changed shape
/// anyway. Stopping is also what keeps the invariant simple: every
/// handle from `recycled` on is untouched and gets freed together.
fn take_recycled(&mut self, binding: u32, drawn: Drawn) -> Option<PrimitiveHandle> {
let h = *self.recycle.get(self.recycle_at)?;
let drawn_matches = (h.pos == NOT_DRAWN) == (drawn == Drawn::No);
if h.binding != binding || h.layer != self.layer || !drawn_matches {
return None;
}
self.recycle_at += 1;
Some(h)
}
fn write_primitive<P: Primitive>(
&mut self,
primitive: P,
region: UiRegion,
drawn: Drawn,
) -> u32 {
let inst = PrimitiveInst {
id: self.id, id: self.id,
primitive, primitive,
region, region,
mask_idx: self.mask, mask_idx: self.mask,
move_idx: self.move_slot, },
}; );
let h = match self.take_recycled(P::BINDING, drawn) {
Some(h) => {
self.render_state.primitives.recycle(&h, inst);
h
}
None => self.render_state.write_primitive(self.layer, drawn, inst),
};
if self.mask != MaskIdx::NONE { if self.mask != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(self.mask); // TODO: I have no clue if this works at all :joy:
self.state.masks.push_ref(self.mask);
} }
let slot = h.slot;
self.own(h);
slot
}
/// Take ownership of a handle this widget just wrote.
fn own(&mut self, h: PrimitiveHandle) {
self.render_state
.primitives
.set_handle_index(h.slot, self.primitives.len() as u32);
self.primitives.push(h); self.primitives.push(h);
} }
/// Writes a primitive to be rendered
pub fn primitive<P: Primitive>(&mut self, primitive: P) { pub fn primitive<P: Primitive>(&mut self, primitive: P) {
self.primitive_at(primitive, self.region) self.primitive_at(primitive, self.region)
} }
/// Resolves a public paint handle to the compact index stored by a GPU
/// primitive and retains the handle for exactly as long as that draw.
pub fn paint(&mut self, paint: &PaintId) -> u32 {
if !self.paints.contains(paint) {
if let Some(i) = self.recycle_paints.iter().position(|old| old == paint) {
self.paints.push(self.recycle_paints.swap_remove(i));
} else {
self.paints.push(paint.clone());
}
}
paint.slot()
}
pub fn paint_value(&mut self, paint: &mut crate::PaintValue) -> u32 {
let paint = paint.resolve(&mut self.rsc.ui_mut().paints);
self.paint(paint)
}
pub fn primitive_within<P: Primitive>(&mut self, primitive: P, region: UiRegion) { pub fn primitive_within<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
self.primitive_at(primitive, region.within(&self.region)); self.primitive_at(primitive, region.within(&self.region));
} }
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
/// region did not change is not redrawn (`draw_inner`'s fast path) and
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) { pub fn set_mask(&mut self, region: UiRegion) {
let paint = self.paint(&PaintId::NONE); assert!(self.mask == MaskIdx::NONE);
let shape = self.write_primitive(RectPrimitive::color(paint), region, Drawn::No); self.mask = self.state.masks.push(Mask { region });
self.set_mask_to(shape);
} }
/// Clip everything this widget draws after this call to `shape`'s /// Draws a widget within this widget's region.
/// own shape -- the first primitive `shape`'s subtree drew, which pub fn widget<W: ?Sized>(&mut self, id: &WidgetHandle<W>) {
/// must already have been drawn this frame self.widget_at(id, self.region);
/// (`UiRenderState::first_primitive`). What `.masked_by()` uses to
/// clip a container's content to the rounded background it draws,
/// with no radius argument anywhere that could fall out of step with
/// the one being drawn.
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
let slot = self.render_state.first_primitive(shape.id()).unwrap_or_else(|| {
panic!(
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
clip to",
self.rsc.widgets().label(shape.id()),
)
});
self.set_mask_to(slot);
} }
fn set_mask_to(&mut self, shape: u32) { pub fn rest_hint(&self, id: &WidgetHandle) -> f32 {
assert!( self.state.widgets.data(id).unwrap().align
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
"set_mask called twice while drawing one widget: the second would replace the first \
rather than nest inside it",
);
let binding = self.render_state.primitives.instance(shape).binding;
assert_eq!(
binding,
RectPrimitive::BINDING,
"a mask's shape must be a rect primitive; primitive {shape} is binding {binding}",
);
let parent = self.mask;
let mask = Mask {
primitive: shape,
parent,
};
let old_parent = if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
MaskIdx::NONE
} else {
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
old
};
if old_parent != parent {
if parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(parent);
}
if old_parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.remove(old_parent);
}
}
self.mask = self.own_mask;
} }
pub fn widget<'p, W: ?Sized>(&'p mut self, id: &StrongWidget<W>) -> DrawResult<'p, 'a> { /// Draws a widget somewhere within this one.
self.widget_at(id, self.region) /// Useful for drawing child widgets in select areas.
pub fn widget_within<W: ?Sized>(&mut self, id: &WidgetHandle<W>, region: UiRegion) {
self.widget_at(id, region.within(&self.region));
} }
pub fn widget_within<'p, W: ?Sized>( fn widget_at<W: ?Sized>(&mut self, id: &WidgetHandle<W>, region: UiRegion) {
&'p mut self,
id: &StrongWidget<W>,
region: UiRegion,
) -> DrawResult<'p, 'a> {
self.widget_at(id, region.within(&self.region))
}
/// Translate this widget's children as one retained subtree, in output
/// pixels. The first call must happen before drawing a child, because the
/// slot becomes the parent of every direct child's ordinary move slot.
/// Once retained, it may be updated later in a redraw (for example after
/// measuring a changed child). All deeper descendants inherit it and the
/// CPU hit-test walk resolves the same translation as the shader.
pub fn set_child_offset(&mut self, offset: Vec2) {
let slot = match self.child_move_slot {
Some(slot) => slot,
None => {
assert!(
self.children.is_empty(),
"a child offset must be created before drawing a child"
);
let parent = self.move_slot.idx() as u32;
let slot = self
.rsc
.ui_mut()
.move_offsets
.push(MoveOffset::new([offset.x, offset.y], parent));
// One ref for this widget's ownership and one on the
// up-link. Direct children take their own refs when their
// move slots are allocated.
self.rsc.ui_mut().move_offsets.push_ref(slot);
self.rsc.ui_mut().move_offsets.push_ref(self.move_slot);
self.child_move_slot = Some(slot);
return;
}
};
let next = [offset.x, offset.y];
if self.rsc.ui().move_offsets[slot.idx()].delta != next {
self.rsc.ui_mut().move_offsets.get_mut(slot).delta = next;
self.render_state.note_move();
}
}
pub fn known_len<W: ?Sized>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Option<LayoutLen> {
let len = if let Some(len) = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis) {
Some(len.fold_dp(self.density()))
} else if !self.reuse_child_sizes[match axis {
Axis::X => 0,
Axis::Y => 1,
}] || self.rsc.widgets().needs_redraw.contains(&id.id())
{
None
} else {
self.render_state
.active
.get(&id.id())
.map(|a| a.size.axis(axis))
};
if len.is_some() && !self.size_dependencies.contains(&id.id()) {
self.size_dependencies.push(id.id());
}
len
}
fn widget_at<'p, W: ?Sized>(
&'p mut self,
id: &StrongWidget<W>,
region: UiRegion,
) -> DrawResult<'p, 'a> {
self.children.push(id.id()); self.children.push(id.id());
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot); self.state
self.render_state.draw_inner( .draw_inner(self.layer, id.id(), region, Some(self.id), self.mask);
self.layer,
id.id(),
region,
Some(self.id),
parent_move_slot.idx() as u32,
self.mask,
Retained::default(),
self.rsc,
);
DrawResult {
painter: self,
child: id.id(),
}
}
pub fn place<'p, W: ?Sized>(
&'p mut self,
id: &StrongWidget<W>,
region: UiRegion,
) -> DrawResult<'p, 'a> {
let region = region.within(&self.region);
let retained = self
.render_state
.active
.get(&id.id())
.map(|active| (active.layer, active.mask));
if self.render_state.place(id.id(), region, self.rsc).is_some() {
} else if let Some((layer, mask)) = retained {
self.children.push(id.id());
self.rsc.widgets_mut().needs_redraw.insert(id.id());
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot);
self.render_state.draw_inner(
layer,
id.id(),
region,
Some(self.id),
parent_move_slot.idx() as u32,
mask,
Retained::default(),
self.rsc,
);
} else {
self.children.push(id.id());
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot);
self.render_state.draw_inner(
self.layer,
id.id(),
region,
Some(self.id),
parent_move_slot.idx() as u32,
self.mask,
Retained::default(),
self.rsc,
);
}
DrawResult {
painter: self,
child: id.id(),
}
}
pub fn place_used<W: ?Sized>(
&mut self,
id: &StrongWidget<W>,
used: Size,
within: UiRegion,
) -> DrawResult<'_, 'a> {
let region = self.fit_region(used, within);
self.place(id, region)
}
pub fn fit_region(&mut self, used: Size, mut within: UiRegion) -> UiRegion {
let mut region = used
.to_uivec2(self.density())
.align(RegionAlign::TOP_LEFT)
.within(&within);
let output = self.output_size();
for axis in [Axis::X, Axis::Y] {
let mut actual = region.within(&self.region);
let mut available = within.within(&self.region);
if actual.axis(axis).len().to_abs(output.axis(axis))
> available.axis(axis).len().to_abs(output.axis(axis))
{
*region.axis_mut(axis) = *within.axis(axis);
}
}
region
} }
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) { pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
self.retain_texture(handle); self.textures.push(handle.clone());
self.write_image(handle.image_index(), region.within(&self.region)); self.primitive_at(handle.primitive(), region.within(&self.region));
} }
pub fn texture(&mut self, handle: &TextureHandle) { pub fn texture(&mut self, handle: &TextureHandle) {
self.retain_texture(handle);
self.write_image(handle.image_index(), self.region);
}
pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) {
self.retain_texture(handle);
self.write_image(handle.image_index(), region);
}
fn retain_texture(&mut self, handle: &TextureHandle) {
if self.textures.contains(handle) {
return;
}
if let Some(i) = self.recycle_textures.iter().position(|old| old == handle) {
self.textures.push(self.recycle_textures.swap_remove(i));
} else {
self.textures.push(handle.clone()); self.textures.push(handle.clone());
} self.primitive(handle.primitive());
} }
fn write_image(&mut self, texture_idx: u32, region: UiRegion) { /// returns (handle, offset from top left)
let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) { pub fn render_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText {
Some(h) => { self.state
self.render_state.primitives.recycle_image( .ui
&h, .text
self.id, .draw(buffer, attrs, &mut self.state.ui.textures)
texture_idx,
region,
self.mask,
self.move_slot,
);
h
}
None => self.render_state.write_image(
self.layer,
self.id,
texture_idx,
region,
self.mask,
self.move_slot,
),
};
if self.mask != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(self.mask);
}
self.own(h);
}
pub fn render_text(&mut self, text: &TextHandle, width: Option<f32>) -> RenderedText {
let density = self.render_state.density;
let ui: &mut UiData = self.rsc.ui_mut();
let (rendered, prepared) = text.render(width, self.id, &mut ui.textures, density);
self.render_state.shape_count += u64::from(prepared);
rendered
}
pub fn render_ellipsis(&mut self, text: &TextHandle) -> RenderedText {
let density = self.render_state.density;
let ui: &mut UiData = self.rsc.ui_mut();
let (rendered, prepared) = text.render_ellipsis(self.id, &mut ui.textures, density);
self.render_state.shape_count += u64::from(prepared);
rendered
}
fn atlas_generation(&self) -> u64 {
self.rsc.ui().text.borrow().atlas.generation()
}
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
// A caller re-emitting quads placed against an atlas that has since
// been cleared draws every glyph from coordinates now holding
// something else. Caught at the submission rather than on screen,
// where it reads as fragments of unrelated letters. `assert_eq!`
// for R1's reason: two integers per laid-out string, not per
// glyph, and the failure is unreadable text on a release build.
assert_eq!(
text.generation,
self.atlas_generation(),
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
re-render after the atlas was cleared",
text.generation,
self.atlas_generation(),
);
let flags_for = |is_color| {
if is_color {
GlyphPrimitive::IS_COLOR
} else {
0
}
};
for paint in text.paints.iter() {
self.paint(paint);
}
for glyph in text.glyphs.iter() {
let mut region = origin;
region.x.end = region.x.start;
region.y.end = region.y.start;
let mut region = region.offset(UiVec2::abs(glyph.offset));
region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32);
region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32);
self.primitive_at(
GlyphPrimitive::new(
glyph.entry.uv_min,
glyph.entry.uv_max,
glyph.entry.layer,
glyph.paint,
flags_for(glyph.entry.is_color),
),
region,
);
}
} }
pub fn region(&self) -> UiRegion { pub fn region(&self) -> UiRegion {
@@ -518,33 +95,27 @@ impl<'a> Painter<'a> {
} }
pub fn output_size(&self) -> Vec2 { pub fn output_size(&self) -> Vec2 {
self.render_state.output_size self.state.output_size
}
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
/// doc. What `LayoutLen::dp`'s `apply_rest` call resolves against.
pub fn density(&self) -> f32 {
self.render_state.density
} }
pub fn px_size(&mut self) -> Vec2 { pub fn px_size(&mut self) -> Vec2 {
self.region.size().to_abs(self.render_state.output_size) self.region.size().to_abs(self.state.output_size)
} }
pub fn text_resources(&mut self) -> Rc<RefCell<TextResources>> { pub fn text_data(&mut self) -> &mut TextData {
self.rsc.ui().text.clone() &mut self.state.text
} }
pub fn child_layer(&mut self) { pub fn child_layer(&mut self) {
self.layer = self.render_state.layers.child(self.layer); self.layer = self.state.layers.child(self.layer);
} }
pub fn next_layer(&mut self) { pub fn next_layer(&mut self) {
self.layer = self.render_state.layers.next(self.layer); self.layer = self.state.layers.next(self.layer);
} }
pub fn label(&self) -> &str { pub fn label(&self) -> &str {
&self.rsc.widgets().data(self.id).unwrap().label &self.state.widgets.data(self.id).unwrap().label
} }
pub fn id(&self) -> &WidgetId { pub fn id(&self) -> &WidgetId {
File diff suppressed because it is too large. Load diff
+3
View File
@@ -0,0 +1,3 @@
// pub struct DynState {
//
// }
+5 -13
View File
@@ -1,6 +1,6 @@
use std::ops::Deref; use std::ops::Deref;
use crate::util::{Dirty, Id, IdNum, IdTracker}; use crate::util::{Id, IdNum, IdTracker};
pub struct Arena<T, I> { pub struct Arena<T, I> {
data: Vec<T>, data: Vec<T>,
@@ -45,7 +45,7 @@ impl<T, I: IdNum> Default for Arena<T, I> {
pub struct TrackedArena<T, I> { pub struct TrackedArena<T, I> {
inner: Arena<T, I>, inner: Arena<T, I>,
refs: Vec<u32>, refs: Vec<u32>,
pub dirty: Dirty, pub changed: bool,
} }
impl<T, I: IdNum> TrackedArena<T, I> { impl<T, I: IdNum> TrackedArena<T, I> {
@@ -53,14 +53,14 @@ impl<T, I: IdNum> TrackedArena<T, I> {
Self { Self {
inner: Arena::default(), inner: Arena::default(),
refs: Vec::new(), refs: Vec::new(),
dirty: Dirty::new_all(), changed: true,
} }
} }
pub fn push(&mut self, value: T) -> Id<I> { pub fn push(&mut self, value: T) -> Id<I> {
self.changed = true;
let id = self.inner.push(value); let id = self.inner.push(value);
let i = id.idx(); let i = id.idx();
self.dirty.mark(i);
if i == self.refs.len() { if i == self.refs.len() {
self.refs.push(0); self.refs.push(0);
} }
@@ -71,15 +71,6 @@ impl<T, I: IdNum> TrackedArena<T, I> {
self.refs[i.idx()] += 1; self.refs[i.idx()] += 1;
} }
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
self.dirty.mark(id.idx());
&mut self.inner.data[id.idx()]
}
pub fn for_upload(&mut self) -> (&[T], &mut Dirty) {
(&self.inner.data, &mut self.dirty)
}
pub fn remove(&mut self, id: Id<I>) -> T pub fn remove(&mut self, id: Id<I>) -> T
where where
T: Copy, T: Copy,
@@ -87,6 +78,7 @@ impl<T, I: IdNum> TrackedArena<T, I> {
let i = id.idx(); let i = id.idx();
self.refs[i] -= 1; self.refs[i] -= 1;
if self.refs[i] == 0 { if self.refs[i] == 0 {
self.changed = true;
self.inner.remove(id) self.inner.remove(id)
} else { } else {
self[i] self[i]
-173
View File
@@ -1,173 +0,0 @@
use std::ops::Range;
#[derive(Default)]
pub struct Dirty {
words: Vec<u64>,
/// Everything is dirty regardless of the bits -- the state after a
/// buffer reallocation, whose contents are undefined, and the state a
/// freshly built arena starts in. Kept as a flag rather than by
/// setting every bit so that it costs nothing to say and cannot be
/// half-applied as the array grows.
all: bool,
}
impl Dirty {
pub fn new_all() -> Self {
Self {
words: Vec::new(),
all: true,
}
}
pub fn mark(&mut self, i: usize) {
if self.all {
return;
}
let word = i / 64;
if word >= self.words.len() {
self.words.resize(word + 1, 0);
}
self.words[word] |= 1 << (i % 64);
}
pub fn contains(&self, i: usize) -> bool {
self.all
|| self
.words
.get(i / 64)
.is_some_and(|word| word & (1 << (i % 64)) != 0)
}
/// Clear one entry that was restored to the value already on the GPU.
/// `all` has no per-entry representation and is used only when every
/// byte must be uploaded regardless of later writes, so it stays set.
pub fn unmark(&mut self, i: usize) {
if self.all {
return;
}
if let Some(word) = self.words.get_mut(i / 64) {
*word &= !(1 << (i % 64));
}
}
/// Everything must be written: the buffer was reallocated (its
/// contents are undefined), or the array was cleared.
pub fn mark_all(&mut self) {
self.all = true;
self.words.clear();
}
pub fn is_clean(&self) -> bool {
!self.all && self.words.iter().all(|w| *w == 0)
}
pub fn ranges(&self, len: usize, gap: usize) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
self.for_each_range(len, gap, |range| ranges.push(range));
ranges
}
pub(crate) fn for_each_range(
&self,
len: usize,
gap: usize,
mut visit: impl FnMut(Range<usize>),
) {
if self.all {
if len > 0 {
visit(0..len);
}
return;
}
let mut pending: Option<Range<usize>> = None;
for (w, word) in self.words.iter().enumerate() {
let mut bits = *word;
while bits != 0 {
let start = w * 64 + bits.trailing_zeros() as usize;
let run = (bits >> (start - w * 64)).trailing_ones() as usize;
let end = (start + run).min(len);
if start >= len {
break;
}
match pending.as_mut() {
Some(last) if start - last.end <= gap => last.end = end,
_ => {
if let Some(range) = pending.replace(start..end) {
visit(range);
}
}
}
bits &= !(((1u128 << run) - 1) as u64) << (start - w * 64);
}
}
if let Some(range) = pending {
visit(range);
}
}
pub fn clear(&mut self) {
self.all = false;
self.words.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn marked(indices: &[usize], len: usize, gap: usize) -> Vec<Range<usize>> {
let mut d = Dirty::default();
for &i in indices {
d.mark(i);
}
d.ranges(len, gap)
}
#[test]
fn adjacent_entries_are_one_range() {
assert_eq!(marked(&[3, 4, 5], 64, 0), vec![3..6]);
}
#[test]
fn a_restored_entry_can_be_unmarked() {
let mut dirty = Dirty::default();
dirty.mark(3);
dirty.mark(5);
assert!(dirty.contains(3));
dirty.unmark(3);
assert!(!dirty.contains(3));
assert_eq!(dirty.ranges(8, 0), vec![5..6]);
}
#[test]
fn a_run_that_crosses_a_word_boundary_is_one_range() {
assert_eq!(marked(&[62, 63, 64, 65], 128, 0), vec![62..66]);
}
#[test]
fn a_gap_wider_than_the_threshold_stays_two_ranges() {
assert_eq!(marked(&[0, 10], 64, 4), vec![0..1, 10..11]);
assert_eq!(marked(&[0, 10], 64, 16), vec![0..11]);
}
#[test]
fn ranges_stop_at_the_length() {
assert_eq!(marked(&[1, 2, 40], 3, 0), vec![1..3]);
}
#[test]
fn mark_all_covers_everything_and_survives_later_marks() {
let mut d = Dirty::new_all();
d.mark(2);
assert_eq!(d.ranges(9, 0), vec![0..9]);
assert!(!d.is_clean());
d.clear();
assert!(d.is_clean());
assert!(d.ranges(9, 0).is_empty());
}
#[test]
fn an_empty_array_has_nothing_to_upload_even_when_all_is_set() {
assert!(Dirty::new_all().ranges(0, 0).is_empty());
}
}
+2
View File
@@ -27,6 +27,8 @@ impl<I: IdNum> IdTracker<I> {
impl<I: IdNum> Id<I> { impl<I: IdNum> Id<I> {
#[allow(dead_code)] #[allow(dead_code)]
/// for debug purposes; should this be exposed?
/// generally you want to use labels with widgets
pub(crate) fn raw(id: I) -> Self { pub(crate) fn raw(id: I) -> Self {
Self(id) Self(id)
} }
+11 -9
View File
@@ -9,20 +9,22 @@ pub const trait DivOr {
fn div_or(self, rhs: Self, other: Self) -> Self; fn div_or(self, rhs: Self, other: Self) -> Self;
} }
const impl DivOr for f32 { impl const DivOr for f32 {
fn div_or(self, rhs: Self, other: Self) -> Self { fn div_or(self, rhs: Self, other: Self) -> Self {
let res = self / rhs; let res = self / rhs;
if res.is_nan() { other } else { res } if res.is_nan() { other } else { res }
} }
} }
const impl< impl<T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy> const
T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy, LerpUtil for T
> LerpUtil for T
{ {
/// linear interpolation
/// from * (1.0 - self) + to * self
fn lerp(self, from: Self, to: Self) -> Self { fn lerp(self, from: Self, to: Self) -> Self {
from + (to - from) * self from + (to - from) * self
} }
/// inverse of lerp
fn lerp_inv(self, from: Self, to: Self) -> Self { fn lerp_inv(self, from: Self, to: Self) -> Self {
(self - from).div_or(to - from, from) (self - from).div_or(to - from, from)
} }
@@ -35,7 +37,7 @@ macro_rules! impl_op {
use super::*; use super::*;
#[allow(unused_imports)] #[allow(unused_imports)]
use std::ops::*; use std::ops::*;
const impl $op for $T { impl const $op for $T {
type Output = Self; type Output = Self;
fn $fn(self, rhs: Self) -> Self::Output { fn $fn(self, rhs: Self) -> Self::Output {
@@ -44,12 +46,12 @@ macro_rules! impl_op {
} }
} }
} }
const impl $opa for $T { impl const $opa for $T {
fn $fna(&mut self, rhs: Self) { fn $fna(&mut self, rhs: Self) {
*self = self.$fn(rhs); *self = self.$fn(rhs);
} }
} }
const impl $op<f32> for $T { impl const $op<f32> for $T {
type Output = Self; type Output = Self;
fn $fn(self, rhs: f32) -> Self::Output { fn $fn(self, rhs: f32) -> Self::Output {
@@ -58,7 +60,7 @@ macro_rules! impl_op {
} }
} }
} }
const impl $op<$T> for f32 { impl const $op<$T> for f32 {
type Output = $T; type Output = $T;
fn $fn(self, rhs: $T) -> Self::Output { fn $fn(self, rhs: $T) -> Self::Output {
@@ -67,7 +69,7 @@ macro_rules! impl_op {
} }
} }
} }
const impl $opa<f32> for $T { impl const $opa<f32> for $T {
fn $fna(&mut self, rhs: f32) { fn $fna(&mut self, rhs: f32) {
*self = self.$fn(rhs); *self = self.$fn(rhs);
} }
-4
View File
@@ -1,11 +1,9 @@
mod arena; mod arena;
mod borrow; mod borrow;
mod change; mod change;
mod dirty;
mod id; mod id;
mod math; mod math;
mod refcount; mod refcount;
mod resources;
mod slot; mod slot;
mod trust; mod trust;
mod typemap; mod typemap;
@@ -14,11 +12,9 @@ mod vec2;
pub use arena::*; pub use arena::*;
pub use borrow::*; pub use borrow::*;
pub use change::*; pub use change::*;
pub use dirty::*;
pub use id::*; pub use id::*;
pub use math::*; pub use math::*;
pub use refcount::*; pub use refcount::*;
pub use resources::*;
pub use slot::*; pub use slot::*;
pub use trust::*; pub use trust::*;
pub use typemap::*; pub use typemap::*;
-414
View File
@@ -1,414 +0,0 @@
use super::{SlotId, SlotVec};
use std::{
cell::{Ref, RefCell, RefMut},
fmt,
hash::{Hash, Hasher},
marker::PhantomData,
rc::Rc,
sync::mpsc::{Receiver, Sender, channel},
};
enum Event {
Clone(SlotId),
Drop(SlotId),
}
struct Entry<T> {
value: T,
strong: Option<u32>,
recycle: bool,
}
/// Generational storage and deferred strong-reference accounting for one kind
/// of UI resource.
pub struct Resources<T> {
entries: SlotVec<Entry<T>>,
send: Sender<Event>,
recv: Receiver<Event>,
}
impl<T> Resources<T> {
pub fn new() -> Self {
let (send, recv) = channel();
Self {
entries: SlotVec::new(),
send,
recv,
}
}
pub fn add(&mut self, value: T) -> StrongRscId<T> {
self.add_with(value, true)
}
pub fn add_unrecycled(&mut self, value: T) -> StrongRscId<T> {
self.add_with(value, false)
}
fn add_with(&mut self, value: T, recycle: bool) -> StrongRscId<T> {
let id = self.entries.add(Entry {
value,
strong: Some(1),
recycle,
});
StrongRscId::new(id, self.send.clone())
}
/// Add an entry whose lifetime is the lifetime of the arena itself.
pub fn add_static(&mut self, value: T) -> WeakRscId<T> {
WeakRscId::new(self.entries.add(Entry {
value,
strong: None,
recycle: false,
}))
}
pub fn apply(&mut self, mut dropped: impl FnMut(SlotId, T)) {
for event in self.recv.try_iter() {
match event {
Event::Clone(id) => {
let entry = self
.entries
.get_mut(id)
.expect("cloned resource id points at a released slot");
let strong = entry
.strong
.as_mut()
.expect("a static resource cannot have a strong id");
*strong = strong.checked_add(1).expect("resource reference overflow");
}
Event::Drop(id) => {
let remove = {
let entry = self
.entries
.get_mut(id)
.expect("dropped resource id points at a released slot");
let strong = entry
.strong
.as_mut()
.expect("a static resource cannot have a strong id");
*strong = strong.checked_sub(1).expect("resource reference underflow");
*strong == 0
};
if remove {
let recycle = self.entries.get(id).unwrap().recycle;
let entry = if recycle {
self.entries.remove(id)
} else {
self.entries.remove_unrecycled(id)
}
.unwrap();
dropped(id, entry.value);
}
}
}
}
}
/// The owning manager must call [`Self::apply`] first so a queued final
/// drop cannot be mistaken for a still-live entry.
pub fn upgrade(&mut self, id: WeakRscId<T>) -> Option<StrongRscId<T>> {
let entry = self.entries.get_mut(id.id)?;
let strong = entry.strong.as_mut()?;
*strong = strong.checked_add(1).expect("resource reference overflow");
Some(StrongRscId::new(id.id, self.send.clone()))
}
pub fn get(&self, id: impl RscId<T>) -> Option<&T> {
Some(&self.entries.get(id.rsc_id())?.value)
}
pub fn get_mut(&mut self, id: impl RscId<T>) -> Option<&mut T> {
Some(&mut self.entries.get_mut(id.rsc_id())?.value)
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.entries.values_mut().map(|entry| &mut entry.value)
}
pub fn capacity(&self) -> usize {
self.entries.capacity()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl<T> Default for Resources<T> {
fn default() -> Self {
Self::new()
}
}
pub trait RscId<T> {
fn rsc_id(&self) -> SlotId;
}
/// A sendable owning ID for one entry in [`Resources`]. It keeps the entry
/// alive but does not provide access to its value.
pub struct StrongRscId<T> {
id: SlotId,
send: Sender<Event>,
ty: PhantomData<fn() -> T>,
}
impl<T> StrongRscId<T> {
fn new(id: SlotId, send: Sender<Event>) -> Self {
Self {
id,
send,
ty: PhantomData,
}
}
pub fn weak(&self) -> WeakRscId<T> {
WeakRscId::new(self.id)
}
pub fn id(&self) -> SlotId {
self.id
}
pub(crate) fn slot(&self) -> u32 {
self.id.slot()
}
}
impl<T> Clone for StrongRscId<T> {
fn clone(&self) -> Self {
let _ = self.send.send(Event::Clone(self.id));
Self::new(self.id, self.send.clone())
}
}
impl<T> Drop for StrongRscId<T> {
fn drop(&mut self) {
let _ = self.send.send(Event::Drop(self.id));
}
}
impl<T> RscId<T> for StrongRscId<T> {
fn rsc_id(&self) -> SlotId {
self.id
}
}
impl<T> RscId<T> for &StrongRscId<T> {
fn rsc_id(&self) -> SlotId {
self.id
}
}
impl<T> fmt::Debug for StrongRscId<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.id.fmt(f)
}
}
impl<T> PartialEq for StrongRscId<T> {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl<T> Eq for StrongRscId<T> {}
impl<T> Hash for StrongRscId<T> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// A sendable, copyable ID that does not keep its [`Resources`] entry alive.
pub struct WeakRscId<T> {
id: SlotId,
ty: PhantomData<fn() -> T>,
}
impl<T> WeakRscId<T> {
fn new(id: SlotId) -> Self {
Self {
id,
ty: PhantomData,
}
}
pub fn id(self) -> SlotId {
self.id
}
pub(crate) fn slot(self) -> u32 {
self.id.slot()
}
}
impl<T> Clone for WeakRscId<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for WeakRscId<T> {}
impl<T> RscId<T> for WeakRscId<T> {
fn rsc_id(&self) -> SlotId {
self.id
}
}
impl<T> RscId<T> for &WeakRscId<T> {
fn rsc_id(&self) -> SlotId {
self.id
}
}
impl<T> fmt::Debug for WeakRscId<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.id.fmt(f)
}
}
impl<T> PartialEq for WeakRscId<T> {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl<T> Eq for WeakRscId<T> {}
impl<T> Hash for WeakRscId<T> {
fn hash<H: Hasher>(&self, state: &mut H) {
self.id.hash(state);
}
}
/// Convenient UI-thread access to a resource through an owning ID. The `Rc`
/// deliberately makes this local; tasks carry strong or weak IDs instead.
pub struct RscHandle<T> {
id: StrongRscId<T>,
resources: Rc<RefCell<Resources<T>>>,
}
impl<T> RscHandle<T> {
pub fn new(id: StrongRscId<T>, resources: Rc<RefCell<Resources<T>>>) -> Self {
Self { id, resources }
}
pub fn add(resources: Rc<RefCell<Resources<T>>>, value: T) -> Self {
let id = resources.borrow_mut().add(value);
Self::new(id, resources)
}
pub fn get(&self) -> Ref<'_, T> {
Ref::map(self.resources.borrow(), |resources| {
resources
.get(&self.id)
.expect("resource handle points at a released slot")
})
}
pub fn get_mut(&mut self) -> RefMut<'_, T> {
RefMut::map(self.resources.borrow_mut(), |resources| {
resources
.get_mut(&self.id)
.expect("resource handle points at a released slot")
})
}
pub(crate) fn get_mut_shared(&self) -> RefMut<'_, T> {
RefMut::map(self.resources.borrow_mut(), |resources| {
resources
.get_mut(&self.id)
.expect("resource handle points at a released slot")
})
}
pub fn strong(&self) -> StrongRscId<T> {
self.id.clone()
}
pub fn weak(&self) -> WeakRscId<T> {
self.id.weak()
}
pub fn id(&self) -> SlotId {
self.id.id()
}
}
impl<T> Clone for RscHandle<T> {
fn clone(&self) -> Self {
Self::new(self.id.clone(), self.resources.clone())
}
}
impl<T> fmt::Debug for RscHandle<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.id.fmt(f)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_slot_lives_until_every_strong_id_is_dropped() {
let mut resources = Resources::new();
let first = resources.add("value");
let weak = first.weak();
let second = first.clone();
drop(first);
resources.apply(|_, _| {});
assert_eq!(resources.get(weak), Some(&"value"));
drop(second);
resources.apply(|_, _| {});
assert_eq!(resources.get(weak), None);
}
#[test]
fn a_weak_id_can_be_upgraded_while_the_resource_is_alive() {
let mut resources = Resources::new();
let first = resources.add("value");
let weak = first.weak();
let second = resources.upgrade(weak).unwrap();
drop(first);
resources.apply(|_, _| {});
assert_eq!(resources.get(&second), Some(&"value"));
}
#[test]
fn strong_and_weak_ids_can_cross_threads() {
fn assert_send<T: Send>() {}
assert_send::<StrongRscId<String>>();
assert_send::<WeakRscId<String>>();
let mut resources = Resources::new();
let first = resources.add("value");
let second = first.clone();
std::thread::spawn(move || drop(second)).join().unwrap();
drop(first);
resources.apply(|_, _| {});
assert!(resources.is_empty());
}
#[test]
fn an_unrecycled_slot_stays_a_hole() {
let mut resources = Resources::new();
let first = resources.add_unrecycled("first");
let first_slot = first.slot();
drop(first);
resources.apply(|_, _| {});
let second = resources.add("second");
assert_ne!(second.slot(), first_slot);
assert_eq!(resources.len(), 1);
}
}
+4 -50
View File
@@ -4,25 +4,9 @@ pub struct SlotId {
genr: u32, genr: u32,
} }
impl SlotId {
pub(crate) fn slot(self) -> u32 {
self.idx
}
/// A stable, collision-free `u64` encoding of this id -- for a caller
/// (accesskit's `NodeId`, today) that wants a flat integer key rather
/// than the two `u32`s. `idx` is offset by one so no real id ever
/// encodes to 0, which callers can then reserve for their own
/// out-of-band root/window node.
pub fn as_u64(&self) -> u64 {
((self.idx as u64) + 1) << 32 | self.genr as u64
}
}
pub struct SlotVec<T> { pub struct SlotVec<T> {
data: Vec<(u32, Option<T>)>, data: Vec<(u32, Option<T>)>,
free: Vec<u32>, free: Vec<u32>,
len: usize,
} }
impl<T> SlotVec<T> { impl<T> SlotVec<T> {
@@ -30,12 +14,11 @@ impl<T> SlotVec<T> {
Self { Self {
data: Default::default(), data: Default::default(),
free: Default::default(), free: Default::default(),
len: 0,
} }
} }
pub fn add(&mut self, x: T) -> SlotId { pub fn add(&mut self, x: T) -> SlotId {
let id = if let Some(idx) = self.free.pop() { if let Some(idx) = self.free.pop() {
let (genr, data) = &mut self.data[idx as usize]; let (genr, data) = &mut self.data[idx as usize];
*data = Some(x); *data = Some(x);
SlotId { idx, genr: *genr } SlotId { idx, genr: *genr }
@@ -44,36 +27,15 @@ impl<T> SlotVec<T> {
let genr = 0; let genr = 0;
self.data.push((genr, Some(x))); self.data.push((genr, Some(x)));
SlotId { idx, genr } SlotId { idx, genr }
}; }
self.len += 1;
id
} }
pub fn free(&mut self, id: SlotId) { pub fn free(&mut self, id: SlotId) {
let _ = self.remove(id);
}
pub fn remove(&mut self, id: SlotId) -> Option<T> {
self.remove_inner(id, true)
}
pub fn remove_unrecycled(&mut self, id: SlotId) -> Option<T> {
self.remove_inner(id, false)
}
fn remove_inner(&mut self, id: SlotId, recycle: bool) -> Option<T> {
let (genr, data) = &mut self.data[id.idx as usize]; let (genr, data) = &mut self.data[id.idx as usize];
if *genr != id.genr {
return None;
}
*genr += 1; *genr += 1;
let value = data.take()?; *data = None;
self.len -= 1;
if recycle {
self.free.push(id.idx); self.free.push(id.idx);
} }
Some(value)
}
pub fn get(&self, id: SlotId) -> Option<&T> { pub fn get(&self, id: SlotId) -> Option<&T> {
let slot = &self.data[id.idx as usize]; let slot = &self.data[id.idx as usize];
@@ -92,20 +54,12 @@ impl<T> SlotVec<T> {
} }
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.len self.data.len() - self.free.len()
} }
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.len() == 0 self.len() == 0
} }
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.data.iter_mut().filter_map(|(_, value)| value.as_mut())
}
pub fn capacity(&self) -> usize {
self.data.len()
}
} }
impl<T> Default for SlotVec<T> { impl<T> Default for SlotVec<T> {
+1
View File
@@ -28,6 +28,7 @@ impl<Trait: ?Sized> TypeMap<Trait> {
} }
fn convert_mut<T: Unsize<Trait>>(entry: &mut Box<Trait>) -> &mut T { fn convert_mut<T: Unsize<Trait>>(entry: &mut Box<Trait>) -> &mut T {
// allegedly this is just what Any does...
unsafe { &mut *(entry.as_mut() as *mut Trait as *mut T) } unsafe { &mut *(entry.as_mut() as *mut Trait as *mut T) }
} }
} }
+2 -1
View File
@@ -61,12 +61,13 @@ impl Vec2 {
} }
} }
// this version looks kinda cool... is it more readable? more annoying to copy and change though
impl_op!(impl Add for Vec2: add x y); impl_op!(impl Add for Vec2: add x y);
impl_op!(Vec2 Sub sub; x y); impl_op!(Vec2 Sub sub; x y);
impl_op!(Vec2 Mul mul; x y); impl_op!(Vec2 Mul mul; x y);
impl_op!(Vec2 Div div; x y); impl_op!(Vec2 Div div; x y);
const impl DivOr for Vec2 { impl const DivOr for Vec2 {
fn div_or(self, rhs: Self, other: Self) -> Self { fn div_or(self, rhs: Self, other: Self) -> Self {
Self { Self {
x: self.x.div_or(rhs.x, other.x), x: self.x.div_or(rhs.x, other.x),
+11 -12
View File
@@ -1,28 +1,27 @@
use crate::Widget; use crate::{RegionAlign, Widget};
pub struct WidgetData { pub struct WidgetData {
pub widget: Box<dyn Widget>, pub widget: Box<dyn Widget>,
pub label: String, pub label: String,
/// alignment used if this does not fill up container
/// and there is no enforced alignment from parent
pub align: RegionAlign,
pub rest_len: Option<f32>,
/// dynamic borrow checking
pub borrowed: bool, pub borrowed: bool,
} }
impl WidgetData { impl WidgetData {
pub fn new<W: Widget>(widget: W) -> Self { pub fn new<W: Widget>(widget: W) -> Self {
let name = std::any::type_name::<W>(); let mut label = std::any::type_name::<W>().to_string();
let label = match (name.find("::"), name.rfind("::")) { if let (Some(first), Some(last)) = (label.find(":"), label.rfind(":")) {
(Some(first), Some(last)) => { label = label.split_at(first).0.to_string() + "::" + label.split_at(last + 1).1;
let suffix = &name[last + 2..];
let mut label = String::with_capacity(first + 2 + suffix.len());
label.push_str(&name[..first]);
label.push_str("::");
label.push_str(suffix);
label
} }
_ => name.to_owned(),
};
Self { Self {
widget: Box::new(widget), widget: Box::new(widget),
align: RegionAlign::CENTER,
label, label,
rest_len: None,
borrowed: false, borrowed: false,
} }
} }
+43 -29
View File
@@ -1,32 +1,44 @@
use std::{marker::Unsize, ops::CoerceUnsized, sync::mpsc::Sender}; use std::{marker::Unsize, ops::CoerceUnsized, sync::mpsc::Sender};
use crate::{ use crate::{
UiRsc, Widget, HasUi, Widget,
util::{RefCounter, SlotId}, util::{RefCounter, SlotId},
}; };
pub type WidgetId = SlotId; pub type WidgetId = SlotId;
pub struct StrongWidget<W: ?Sized = dyn Widget> { /// An identifier for a widget that can index a UI or event ctx to get it.
/// This is a strong handle that does not impl Clone, and when it is dropped,
/// a signal is sent to the owning UI to clean up the resources.
///
/// TODO: ergonomic clones when they get put in rust-analyzer & don't cause ICEs?
pub struct WidgetHandle<W: ?Sized = dyn Widget> {
pub(super) id: WidgetId, pub(super) id: WidgetId,
counter: RefCounter, counter: RefCounter,
send: Sender<WidgetId>, send: Sender<WidgetId>,
ty: *const W, ty: *const W,
} }
pub struct WeakWidget<W: ?Sized = dyn Widget> { /// A weak handle to a widget.
/// Will not keep it alive, but can still be used for indexing like WidgetHandle.
pub struct WidgetRef<W: ?Sized = dyn Widget> {
pub(super) id: WidgetId, pub(super) id: WidgetId,
#[allow(unused)] #[allow(unused)]
ty: *const W, ty: *const W,
} }
impl<W: Widget + ?Sized + Unsize<dyn Widget>> StrongWidget<W> { pub struct WidgetHandles<W: ?Sized = dyn Widget> {
pub fn any(self) -> StrongWidget<dyn Widget> { pub h: WidgetHandle<W>,
pub r: WidgetRef<W>,
}
impl<W: Widget + ?Sized + Unsize<dyn Widget>> WidgetHandle<W> {
pub fn any(self) -> WidgetHandle<dyn Widget> {
self self
} }
} }
impl<W: ?Sized> StrongWidget<W> { impl<W: ?Sized> WidgetHandle<W> {
pub(crate) fn new(id: WidgetId, send: Sender<WidgetId>) -> Self { pub(crate) fn new(id: WidgetId, send: Sender<WidgetId>) -> Self {
Self { Self {
id, id,
@@ -44,13 +56,18 @@ impl<W: ?Sized> StrongWidget<W> {
self.counter.refs() self.counter.refs()
} }
pub fn weak(&self) -> WeakWidget<W> { pub fn weak(&self) -> WidgetRef<W> {
let Self { ty, id, .. } = *self; let Self { ty, id, .. } = *self;
WeakWidget { ty, id } WidgetRef { ty, id }
}
pub fn handles(self) -> WidgetHandles<W> {
let r = self.weak();
WidgetHandles { h: self, r }
} }
} }
impl<W: ?Sized> WeakWidget<W> { impl<W: ?Sized> WidgetRef<W> {
pub(crate) fn new(id: WidgetId) -> Self { pub(crate) fn new(id: WidgetId) -> Self {
Self { id, ty: null_ptr() } Self { id, ty: null_ptr() }
} }
@@ -60,12 +77,12 @@ impl<W: ?Sized> WeakWidget<W> {
} }
#[track_caller] #[track_caller]
pub fn upgrade(self, ui: &mut impl UiRsc) -> StrongWidget<W> { pub fn upgrade(self, ui: &mut impl HasUi) -> WidgetHandle<W> {
ui.widgets_mut().upgrade(self) ui.ui_mut().widgets.upgrade(self)
} }
} }
impl<W: ?Sized> Drop for StrongWidget<W> { impl<W: ?Sized> Drop for WidgetHandle<W> {
fn drop(&mut self) { fn drop(&mut self) {
if self.counter.drop() { if self.counter.drop() {
let _ = self.send.send(self.id); let _ = self.send.send(self.id);
@@ -73,29 +90,29 @@ impl<W: ?Sized> Drop for StrongWidget<W> {
} }
} }
pub trait WidgetIdFn<Rsc, W: ?Sized = dyn Widget>: FnOnce(&mut Rsc) -> WeakWidget<W> {} pub trait WidgetIdFn<State, W: ?Sized = dyn Widget>: FnOnce(&mut State) -> WidgetRef<W> {}
impl<Rsc, W: ?Sized, F: FnOnce(&mut Rsc) -> WeakWidget<W>> WidgetIdFn<Rsc, W> for F {} impl<State, W: ?Sized, F: FnOnce(&mut State) -> WidgetRef<W>> WidgetIdFn<State, W> for F {}
pub trait IdLike { pub trait IdLike {
type Widget: ?Sized; type Widget: ?Sized;
fn id(&self) -> WidgetId; fn id(&self) -> WidgetId;
} }
impl<W: ?Sized> IdLike for &StrongWidget<W> { impl<W: ?Sized> IdLike for &WidgetHandle<W> {
type Widget = W; type Widget = W;
fn id(&self) -> WidgetId { fn id(&self) -> WidgetId {
self.id self.id
} }
} }
impl<W: ?Sized> IdLike for StrongWidget<W> { impl<W: ?Sized> IdLike for WidgetHandle<W> {
type Widget = W; type Widget = W;
fn id(&self) -> WidgetId { fn id(&self) -> WidgetId {
self.id self.id
} }
} }
impl<W: ?Sized> IdLike for WeakWidget<W> { impl<W: ?Sized> IdLike for WidgetRef<W> {
type Widget = W; type Widget = W;
fn id(&self) -> WidgetId { fn id(&self) -> WidgetId {
self.id self.id
@@ -109,38 +126,38 @@ impl IdLike for WidgetId {
} }
} }
impl<T: ?Sized + Unsize<U>, U: ?Sized> CoerceUnsized<StrongWidget<U>> for StrongWidget<T> {} impl<T: ?Sized + Unsize<U>, U: ?Sized> CoerceUnsized<WidgetHandle<U>> for WidgetHandle<T> {}
impl<T: ?Sized + Unsize<U>, U: ?Sized> CoerceUnsized<WeakWidget<U>> for WeakWidget<T> {} impl<T: ?Sized + Unsize<U>, U: ?Sized> CoerceUnsized<WidgetRef<U>> for WidgetRef<T> {}
impl<W: ?Sized> Clone for WeakWidget<W> { impl<W: ?Sized> Clone for WidgetRef<W> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
*self *self
} }
} }
impl<W: ?Sized> Copy for WeakWidget<W> {} impl<W: ?Sized> Copy for WidgetRef<W> {}
impl<W: ?Sized> PartialEq for WeakWidget<W> { impl<W: ?Sized> PartialEq for WidgetRef<W> {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.id == other.id self.id == other.id
} }
} }
impl<W> PartialEq for StrongWidget<W> { impl<W> PartialEq for WidgetHandle<W> {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.id == other.id self.id == other.id
} }
} }
impl<W> std::fmt::Debug for StrongWidget<W> { impl<W> std::fmt::Debug for WidgetHandle<W> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.id.fmt(f) self.id.fmt(f)
} }
} }
impl<'a, W: Widget + 'a, State: UiRsc> FnOnce<(&'a mut State,)> for WeakWidget<W> { impl<'a, W: Widget + 'a, State: HasUi> FnOnce<(&'a mut State,)> for WidgetRef<W> {
type Output = &'a mut W; type Output = &'a mut W;
extern "rust-call" fn call_once(self, args: (&'a mut State,)) -> Self::Output { extern "rust-call" fn call_once(self, args: (&'a mut State,)) -> Self::Output {
&mut args.0.widgets_mut()[self] &mut args.0.ui_mut()[self]
} }
} }
@@ -152,6 +169,3 @@ fn null_ptr<W: ?Sized>() -> *const W {
unsafe { std::mem::transmute_copy(&[0usize; 1]) } unsafe { std::mem::transmute_copy(&[0usize; 1]) }
} }
} }
unsafe impl<W: ?Sized> Send for WeakWidget<W> {}
unsafe impl<W: ?Sized> Sync for WeakWidget<W> {}
+33 -21
View File
@@ -1,59 +1,71 @@
use crate::UiRsc; use crate::{HasUi, Ui};
use super::*; use super::*;
use std::marker::Unsize; use std::marker::Unsize;
pub trait WidgetLike<Rsc: UiRsc, Tag>: Sized { pub trait StateLike<State> {
fn as_state(&mut self) -> &mut State;
}
impl StateLike<Ui> for Ui {
fn as_state(&mut self) -> &mut Ui {
self
}
}
pub trait WidgetLike<State: HasUi + StateLike<State>, Tag>: Sized {
type Widget: Widget + ?Sized + Unsize<dyn Widget>; type Widget: Widget + ?Sized + Unsize<dyn Widget>;
fn add(self, rsc: &mut Rsc) -> WeakWidget<Self::Widget>; fn add(self, state: &mut impl StateLike<State>) -> WidgetRef<Self::Widget>;
fn add_strong(self, rsc: &mut Rsc) -> StrongWidget<Self::Widget> { fn add_strong(self, state: &mut impl StateLike<State>) -> WidgetHandle<Self::Widget> {
self.add(rsc).upgrade(rsc) self.add(state).upgrade(state.as_state().ui_mut())
} }
fn with_id<W2>( fn with_id<W2>(
self, self,
f: impl FnOnce(&mut Rsc, WeakWidget<Self::Widget>) -> WeakWidget<W2>, f: impl FnOnce(&mut State, WidgetRef<Self::Widget>) -> WidgetRef<W2>,
) -> impl WidgetIdFn<Rsc, W2> { ) -> impl WidgetIdFn<State, W2> {
move |state| { move |state| {
let id = self.add(state); let id = self.add(state);
f(state, id) f(state, id)
} }
} }
fn set_root(self, rsc: &mut Rsc, root: &mut impl HasRoot<Rsc>) { fn set_root(self, state: &mut impl StateLike<State>) {
let id = self.add_strong(rsc); let id = self.add(state);
root.set_root(rsc, id); let ui = state.as_state().ui_mut();
ui.root = Some(id.upgrade(ui));
}
fn handles(self, state: &mut impl StateLike<State>) -> WidgetHandles<Self::Widget> {
self.add(state).upgrade(state.as_state().ui_mut()).handles()
} }
} }
pub trait HasRoot<Rsc> { pub trait WidgetArrLike<State, const LEN: usize, Tag> {
fn set_root(&mut self, rsc: &mut Rsc, root: StrongWidget);
}
pub trait WidgetArrLike<Rsc, const LEN: usize, Tag> {
#[track_caller] #[track_caller]
fn add(self, state: &mut Rsc) -> WidgetArr<LEN>; fn add(self, state: &mut impl StateLike<State>) -> WidgetArr<LEN>;
} }
impl<Rsc, const LEN: usize> WidgetArrLike<Rsc, LEN, ArrTag> for WidgetArr<LEN> { impl<State, const LEN: usize> WidgetArrLike<State, LEN, ArrTag> for WidgetArr<LEN> {
fn add(self, _: &mut Rsc) -> WidgetArr<LEN> { fn add(self, _: &mut impl StateLike<State>) -> WidgetArr<LEN> {
self self
} }
} }
// variadic generics please save us
macro_rules! impl_widget_arr { macro_rules! impl_widget_arr {
($n:expr;$($W:ident)*) => { ($n:expr;$($W:ident)*) => {
impl_widget_arr!($n;$($W)*;$(${concat($W,Tag)})*); impl_widget_arr!($n;$($W)*;$(${concat($W,Tag)})*);
}; };
($n:expr;$($W:ident)*;$($Tag:ident)*) => { ($n:expr;$($W:ident)*;$($Tag:ident)*) => {
impl<Rsc: UiRsc, $($W: WidgetLike<Rsc, $Tag>,$Tag,)*> WidgetArrLike<Rsc, $n, ($($Tag,)*)> for ($($W,)*) { impl<State: HasUi + StateLike<State>, $($W: WidgetLike<State, $Tag>,$Tag,)*> WidgetArrLike<State, $n, ($($Tag,)*)> for ($($W,)*) {
fn add(self, rsc: &mut Rsc) -> WidgetArr<$n> { fn add(self, state: &mut impl StateLike<State>) -> WidgetArr<$n> {
#[allow(non_snake_case)] #[allow(non_snake_case)]
let ($($W,)*) = self; let ($($W,)*) = self;
WidgetArr::new( WidgetArr::new(
[$($W.add(rsc).upgrade(rsc),)*], [$($W.add(state).upgrade(state.as_state().ui_mut()),)*],
) )
} }
} }
+11 -50
View File
@@ -1,66 +1,24 @@
use crate::{Axis, LayoutLen, Painter, Size}; use crate::Painter;
use std::any::Any; use std::any::Any;
mod data; mod data;
mod handle; mod handle;
mod like; mod like;
mod tag; mod tag;
mod view;
mod widgets; mod widgets;
pub use data::*; pub use data::*;
pub use handle::*; pub use handle::*;
pub use like::*; pub use like::*;
pub use tag::*; pub use tag::*;
pub use view::*;
pub use widgets::*; pub use widgets::*;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ChildOrder {
/// The order in which the parent drew its children.
#[default]
Draw,
/// Ascending visual position on one screen axis. Equal positions keep
/// draw order; the resolved coordinates are sorted only when queried.
Axis(Axis),
}
pub trait Widget: Any { pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter); fn draw(&mut self, painter: &mut Painter);
fn size_hint(&self, _axis: Axis) -> Option<LayoutLen> {
None
}
fn is_size_independent(&self) -> bool {
false
}
fn requires_exact_region(&self) -> bool {
false
}
fn access_role(&self) -> accesskit::Role {
accesskit::Role::Unknown
}
fn child_order(&self) -> ChildOrder {
ChildOrder::Draw
}
} }
impl Widget for () { impl Widget for () {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, _: &mut Painter) {}
painter.set_size(Size::ZERO);
}
fn is_size_independent(&self) -> bool {
true
}
fn size_hint(&self, _axis: Axis) -> Option<LayoutLen> {
Some(LayoutLen::ZERO)
}
} }
impl dyn Widget { impl dyn Widget {
@@ -73,31 +31,34 @@ impl dyn Widget {
} }
} }
/// A function that returns a widget given a UI.
/// Useful for defining trait functions on widgets that create a parent widget so that the children
/// don't need to be IDs yet
pub trait WidgetFn<State, W: Widget + ?Sized>: FnOnce(&mut State) -> W {} pub trait WidgetFn<State, W: Widget + ?Sized>: FnOnce(&mut State) -> W {}
impl<State, W: Widget + ?Sized, F: FnOnce(&mut State) -> W> WidgetFn<State, W> for F {} impl<State, W: Widget + ?Sized, F: FnOnce(&mut State) -> W> WidgetFn<State, W> for F {}
pub struct WidgetArr<const LEN: usize> { pub struct WidgetArr<const LEN: usize> {
pub arr: [StrongWidget; LEN], pub arr: [WidgetHandle; LEN],
} }
impl<const LEN: usize> WidgetArr<LEN> { impl<const LEN: usize> WidgetArr<LEN> {
pub fn new(arr: [StrongWidget; LEN]) -> Self { pub fn new(arr: [WidgetHandle; LEN]) -> Self {
Self { arr } Self { arr }
} }
} }
pub trait WidgetOption<State> { pub trait WidgetOption<State> {
fn get(self, state: &mut State) -> Option<StrongWidget>; fn get(self, state: &mut State) -> Option<WidgetHandle>;
} }
impl<State> WidgetOption<State> for () { impl<State> WidgetOption<State> for () {
fn get(self, _: &mut State) -> Option<StrongWidget> { fn get(self, _: &mut State) -> Option<WidgetHandle> {
None None
} }
} }
impl<State, F: FnOnce(&mut State) -> Option<StrongWidget>> WidgetOption<State> for F { impl<State, F: FnOnce(&mut State) -> Option<WidgetHandle>> WidgetOption<State> for F {
fn get(self, state: &mut State) -> Option<StrongWidget> { fn get(self, state: &mut State) -> Option<WidgetHandle> {
self(state) self(state)
} }
} }
+38 -43
View File
@@ -1,63 +1,58 @@
use super::*; use super::*;
use crate::UiRsc; use crate::HasUi;
use std::marker::Unsize; use std::marker::Unsize;
pub struct WidgetTag; pub struct WidgetTag;
impl<Rsc: UiRsc, W: Widget> WidgetLike<Rsc, WidgetTag> for W { impl<State: HasUi + StateLike<State>, W: Widget> WidgetLike<State, WidgetTag> for W {
type Widget = W; type Widget = W;
fn add(self, rsc: &mut Rsc) -> WeakWidget<W> { fn add(self, state: &mut impl StateLike<State>) -> WidgetRef<W> {
let w = rsc.ui_mut().widgets.add_weak(self); state.as_state().get_mut().widgets.add_weak(self)
rsc.on_add(w);
w
} }
} }
pub struct FnTag; pub struct FnTag;
impl<Rsc: UiRsc, W: Widget, F: FnOnce(&mut Rsc) -> W> WidgetLike<Rsc, FnTag> for F { impl<State: HasUi + StateLike<State>, W: Widget, F: FnOnce(&mut State) -> W>
type Widget = W; WidgetLike<State, FnTag> for F
fn add(self, rsc: &mut Rsc) -> WeakWidget<W> {
self(rsc).add(rsc)
}
}
pub trait WidgetFnTrait<Rsc> {
type Widget: Widget;
fn run(self, rsc: &mut Rsc) -> Self::Widget;
}
pub struct FnTraitTag;
impl<Rsc: UiRsc, T: WidgetFnTrait<Rsc>> WidgetLike<Rsc, FnTraitTag> for T {
type Widget = T::Widget;
#[track_caller]
fn add(self, rsc: &mut Rsc) -> WeakWidget<T::Widget> {
self.run(rsc).add(rsc)
}
}
pub struct RefTag;
impl<Rsc: UiRsc, W: ?Sized + Widget + Unsize<dyn Widget>> WidgetLike<Rsc, RefTag>
for WeakWidget<W>
{ {
type Widget = W; type Widget = W;
fn add(self, _: &mut Rsc) -> WeakWidget<W> { fn add(self, state: &mut impl StateLike<State>) -> WidgetRef<W> {
self(state.as_state()).add(state)
}
}
pub trait WidgetFnTrait<State> {
type Widget: Widget;
fn run(self, state: &mut State) -> Self::Widget;
}
pub struct FnTraitTag;
impl<State: HasUi + StateLike<State>, T: WidgetFnTrait<State>> WidgetLike<State, FnTraitTag> for T {
type Widget = T::Widget;
#[track_caller]
fn add(self, state: &mut impl StateLike<State>) -> WidgetRef<T::Widget> {
self.run(state.as_state()).add(state)
}
}
pub struct IdTag;
impl<State: HasUi + StateLike<State>, W: ?Sized + Widget + Unsize<dyn Widget>>
WidgetLike<State, IdTag> for WidgetRef<W>
{
type Widget = W;
fn add(self, _: &mut impl StateLike<State>) -> WidgetRef<W> {
self self
} }
} }
pub struct RefFnTag; pub struct IdFnTag;
impl<Rsc: UiRsc, W: ?Sized + Widget + Unsize<dyn Widget>, F: FnOnce(&mut Rsc) -> WeakWidget<W>> impl<
WidgetLike<Rsc, RefFnTag> for F State: HasUi + StateLike<State>,
W: ?Sized + Widget + Unsize<dyn Widget>,
F: FnOnce(&mut State) -> WidgetRef<W>,
> WidgetLike<State, IdFnTag> for F
{ {
type Widget = W; type Widget = W;
fn add(self, rsc: &mut Rsc) -> WeakWidget<W> { fn add(self, state: &mut impl StateLike<State>) -> WidgetRef<W> {
self(rsc) self(state.as_state())
}
}
pub struct ViewTag;
impl<Rsc: UiRsc, V: WidgetView> WidgetLike<Rsc, ViewTag> for V {
type Widget = V::Widget;
fn add(self, _: &mut Rsc) -> WeakWidget<Self::Widget> {
self.root()
} }
} }
-24
View File
@@ -1,24 +0,0 @@
use std::marker::Unsize;
use crate::{IdLike, WeakWidget, Widget};
pub trait WidgetView {
type Widget: Widget + ?Sized + Unsize<dyn Widget>;
fn root(&self) -> WeakWidget<Self::Widget>;
}
pub trait HasWidget {
type Widget: Widget + ?Sized + Unsize<dyn Widget>;
}
impl<W: Widget + Unsize<dyn Widget> + ?Sized> HasWidget for WeakWidget<W> {
type Widget = W;
}
impl<WV: WidgetView> IdLike for WV {
type Widget = WV::Widget;
fn id(&self) -> super::WidgetId {
self.root().id
}
}
+30 -67
View File
@@ -1,7 +1,7 @@
use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::mpsc::Sender;
use crate::{ use crate::{
IdLike, StrongWidget, WeakWidget, Widget, WidgetData, WidgetId, IdLike, Widget, WidgetData, WidgetHandle, WidgetId, WidgetRef,
util::{DynBorrower, HashSet, SlotVec, forget_mut, to_mut}, util::{DynBorrower, HashSet, SlotVec, forget_mut, to_mut},
}; };
@@ -9,21 +9,16 @@ pub struct Widgets {
pub needs_redraw: HashSet<WidgetId>, pub needs_redraw: HashSet<WidgetId>,
vec: SlotVec<WidgetData>, vec: SlotVec<WidgetData>,
send: Sender<WidgetId>, send: Sender<WidgetId>,
recv: Receiver<WidgetId>,
pub(crate) waiting: HashSet<WidgetId>, pub(crate) waiting: HashSet<WidgetId>,
named: HashSet<WidgetId>,
} }
impl Widgets { impl Widgets {
pub fn new() -> Self { pub fn new(send: Sender<WidgetId>) -> Self {
let (send, recv) = channel();
Self { Self {
needs_redraw: Default::default(), needs_redraw: Default::default(),
vec: Default::default(), vec: Default::default(),
waiting: Default::default(), waiting: Default::default(),
named: Default::default(),
send, send,
recv,
} }
} }
@@ -31,16 +26,9 @@ impl Widgets {
!self.needs_redraw.is_empty() !self.needs_redraw.is_empty()
} }
pub fn get_dyn(&self, id: WidgetId) -> Option<&dyn Widget> { /// get_dyn but dynamic borrow checking of widgets
Some(self.vec.get(id)?.widget.as_ref()) /// lets you do recursive (tree) operations, like the painter does
} pub(crate) fn get_dyn<'a>(&self, id: WidgetId) -> WidgetWrapper<'a> {
pub fn get_dyn_mut(&mut self, id: WidgetId) -> Option<&mut dyn Widget> {
self.needs_redraw.insert(id);
Some(self.vec.get_mut(id)?.widget.as_mut())
}
pub(crate) fn get_dyn_dynamic<'a>(&self, id: WidgetId) -> WidgetWrapper<'a> {
// SAFETY: must guarantee no other mutable references to this widget exist // SAFETY: must guarantee no other mutable references to this widget exist
// done through the borrow variable // done through the borrow variable
let data = unsafe { forget_mut(to_mut(self.vec.get(id).unwrap())) }; let data = unsafe { forget_mut(to_mut(self.vec.get(id).unwrap())) };
@@ -54,29 +42,41 @@ impl Widgets {
where where
I::Widget: Sized + Widget, I::Widget: Sized + Widget,
{ {
self.get_dyn(id.id())?.as_any().downcast_ref() self.vec
.get(id.id())?
.widget
.as_ref()
.as_any()
.downcast_ref()
} }
pub fn get_mut<I: IdLike>(&mut self, id: &I) -> Option<&mut I::Widget> pub fn get_mut<I: IdLike>(&mut self, id: &I) -> Option<&mut I::Widget>
where where
I::Widget: Sized + Widget, I::Widget: Sized + Widget,
{ {
self.get_dyn_mut(id.id())?.as_any_mut().downcast_mut() let id = id.id();
self.needs_redraw.insert(id);
self.vec
.get_mut(id)?
.widget
.as_mut()
.as_any_mut()
.downcast_mut()
} }
pub fn add_strong<W: Widget>(&mut self, widget: W) -> StrongWidget<W> { pub fn add_strong<W: Widget>(&mut self, widget: W) -> WidgetHandle<W> {
let id = self.vec.add(WidgetData::new(widget)); let id = self.vec.add(WidgetData::new(widget));
StrongWidget::new(id, self.send.clone()) WidgetHandle::new(id, self.send.clone())
} }
pub fn add_weak<W: Widget>(&mut self, widget: W) -> WeakWidget<W> { pub fn add_weak<W: Widget>(&mut self, widget: W) -> WidgetRef<W> {
let id = self.vec.add(WidgetData::new(widget)); let id = self.vec.add(WidgetData::new(widget));
self.waiting.insert(id); self.waiting.insert(id);
WeakWidget::new(id) WidgetRef::new(id)
} }
#[track_caller] #[track_caller]
pub fn upgrade<W: ?Sized>(&mut self, rf: WeakWidget<W>) -> StrongWidget<W> { pub fn upgrade<W: ?Sized>(&mut self, rf: WidgetRef<W>) -> WidgetHandle<W> {
if !self.waiting.remove(&rf.id()) { if !self.waiting.remove(&rf.id()) {
let label = self.label(rf); let label = self.label(rf);
let id = rf.id(); let id = rf.id();
@@ -84,7 +84,7 @@ impl Widgets {
"widget '{label}' ({id:?}) was already added\ncannot add a widget twice; consider creating two" "widget '{label}' ({id:?}) was already added\ncannot add a widget twice; consider creating two"
) )
} }
StrongWidget::new(rf.id(), self.send.clone()) WidgetHandle::new(rf.id(), self.send.clone())
} }
pub fn data(&self, id: impl IdLike) -> Option<&WidgetData> { pub fn data(&self, id: impl IdLike) -> Option<&WidgetData> {
@@ -95,25 +95,14 @@ impl Widgets {
&self.data(id.id()).unwrap().label &self.data(id.id()).unwrap().label
} }
pub fn set_label(&mut self, id: impl IdLike, label: String) {
let id = id.id();
self.data_mut(id).unwrap().label = label;
self.named.insert(id);
}
pub fn named(&self) -> impl Iterator<Item = WidgetId> + '_ {
self.named.iter().copied()
}
pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> { pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> {
self.vec.get_mut(id.id()) self.vec.get_mut(id.id())
} }
pub fn free_next(&mut self) -> Option<WidgetId> { pub fn delete(&mut self, id: impl IdLike) {
let next = self.recv.try_recv().ok()?; self.vec.free(id.id());
self.vec.free(next); // not sure if there's any point in this
self.named.remove(&next); // self.updates.remove(&id);
Some(next)
} }
#[allow(clippy::len_without_is_empty)] #[allow(clippy::len_without_is_empty)]
@@ -122,30 +111,4 @@ impl Widgets {
} }
} }
impl Default for Widgets {
fn default() -> Self {
Self::new()
}
}
pub type WidgetWrapper<'a> = DynBorrower<'a, dyn Widget>; pub type WidgetWrapper<'a> = DynBorrower<'a, dyn Widget>;
impl<I: IdLike> std::ops::Index<I> for Widgets
where
I::Widget: Sized + Widget,
{
type Output = I::Widget;
fn index(&self, id: I) -> &Self::Output {
self.get(&id).unwrap()
}
}
impl<I: IdLike> std::ops::IndexMut<I> for Widgets
where
I::Widget: Sized + Widget,
{
fn index_mut(&mut self, id: I) -> &mut Self::Output {
self.get_mut(&id).unwrap()
}
}
-12
View File
@@ -1,12 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[iris::android_init]
fn create(
ui_state: &mut AndroidUiState,
rsc: &mut StdRsc<AndroidUiState>,
) {
let _ = app::build(rsc, ui_state);
}
-61
View File
@@ -1,61 +0,0 @@
use iris::prelude::*;
use winit::event::WindowEvent;
#[path = "lib.rs"]
mod app;
const SETTLE_FRAMES: usize = 4;
const FRAMES: usize = 6;
#[derive(DesktopUiState)]
struct State {
ui_state: DesktopUiState,
span: WeakWidget<Span>,
frame: usize,
appended: bool,
}
impl DesktopAppState for State {
fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc<Self>) -> Self {
let span = app::build(rsc, &mut ui_state);
Self {
ui_state,
span,
frame: 0,
appended: false,
}
}
fn window_event(&mut self, event: WindowEvent, rsc: &mut StdRsc<Self>) {
if !matches!(event, WindowEvent::RedrawRequested) {
return;
}
self.frame += 1;
let creates = self.ui_state.renderer.ui.take_image_bind_group_creates();
println!(
"BENCH_IMAGES frame={} bind_group_creates={creates}",
self.frame
);
if self.frame == SETTLE_FRAMES && !self.appended {
self.appended = true;
let img = iris::image::DynamicImage::new_rgba8(32, 32);
let widget = image::<StdRsc<Self>>(img)(rsc);
let widget = rsc.ui.widgets.add_strong(widget);
rsc.ui
.widgets
.get_mut(&self.span)
.unwrap()
.push(widget.any());
println!("BENCH_IMAGES appended one image after settling");
}
if self.frame < FRAMES {
self.ui_state.window.request_redraw();
} else {
std::process::exit(0);
}
}
}
fn main() {
DesktopApp::<State>::run();
}
-24
View File
@@ -1,24 +0,0 @@
use iris::prelude::*;
const ROWS: usize = 1000;
pub(crate) fn build<Rsc: UiRsc>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot<Rsc>,
) -> WeakWidget<Span> {
let mut span = Span::empty(Dir::DOWN);
for _ in 0..ROWS {
let img = iris::image::DynamicImage::new_rgba8(32, 32);
let widget = image::<Rsc>(img)(rsc);
let widget = rsc.ui_mut().widgets.add_strong(widget);
span.push(widget.any());
}
let span = rsc.ui_mut().widgets.add_strong(span);
let span_weak = span.weak();
let root = rsc
.ui_mut()
.widgets
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End));
ui_state.set_root(rsc, root.any());
span_weak
}
-12
View File
@@ -1,12 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[iris::android_init]
fn create(
ui_state: &mut AndroidUiState,
rsc: &mut StdRsc<AndroidUiState>,
) {
app::build(rsc, ui_state);
}
-10
View File
@@ -1,10 +0,0 @@
use iris::prelude::*;
use winit::{dpi::LogicalSize, window::WindowAttributes};
#[path = "lib.rs"]
mod app;
fn main() {
let attributes = WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0));
DesktopApp::run_with_attributes(attributes, app::build);
}
-64
View File
@@ -1,64 +0,0 @@
use iris::prelude::*;
const ROWS: usize = 800;
const IMAGE_EVERY: usize = 12;
fn row_text(i: usize) -> String {
const SENTENCE: &str =
"Iris lays out this row once and moves it on scroll, never re-laying it out. ";
let repeats = 1 + (i * 7) % 5;
format!("Message {i}: {}", SENTENCE.repeat(repeats))
}
fn row_image(i: usize) -> iris::image::DynamicImage {
let hue = ((i * 47) % 255) as u8;
iris::image::RgbaImage::from_pixel(48, 48, iris::image::Rgba([hue, 128, 255 - hue, 255])).into()
}
fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
let tint = if i.is_multiple_of(2) {
Srgba8::rgb(120, 130, 170)
} else {
Srgba8::rgb(70, 80, 140)
};
let text_color = PaintId::BLACK;
if i.is_multiple_of(IMAGE_EVERY) {
let text = wtext(row_text(i))
.overflow(TextOverflow::Wrap)
.color(text_color)
.add_strong(rsc)
.any();
let img = image::<Rsc>(row_image(i))(rsc);
let img = rsc.widgets_mut().add_strong(img).any();
let mut span = Span::empty(Dir::DOWN);
span.push(text);
span.push(img);
span.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
} else {
wtext(row_text(i))
.overflow(TextOverflow::Wrap)
.color(text_color)
.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
}
}
pub(crate) fn build<Rsc: HasEvents>(rsc: &mut Rsc, ui_state: &mut impl HasRoot<Rsc>) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for i in 0..ROWS {
let row = build_row(rsc, i);
list.push_back(LazyItem::new(i as u64, row));
}
let root = list
.scrollable()
.masked()
.background(rect(PaintId::WHITE))
.add_strong(rsc);
ui_state.set_root(rsc, root.any());
}
+20
View File
@@ -0,0 +1,20 @@
use iris::prelude::*;
fn main() {
App::<State>::run();
}
#[default_ui_state]
struct State {}
impl DefaultAppState for State {
fn new(ui_state: DefaultUiState, _proxy: Proxy<Self::Event>) -> Self {
let mut ui = Ui::new();
rect(Color::RED).set_root(&mut ui);
Self {
ui,
ui_state,
events: EventManager::default(),
}
}
}
-12
View File
@@ -1,12 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[iris::android_init]
fn create(
ui_state: &mut AndroidUiState,
rsc: &mut StdRsc<AndroidUiState>,
) {
app::build(rsc, ui_state);
}
-8
View File
@@ -1,8 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
fn main() {
DesktopApp::run_with(app::build);
}
-5
View File
@@ -1,5 +0,0 @@
use iris::prelude::*;
pub(crate) fn build<Rsc: UiRsc>(rsc: &mut Rsc, ui_state: &mut impl HasRoot<Rsc>) {
rect(PaintId::RED).set_root(rsc, ui_state);
}
-33
View File
@@ -1,33 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[derive(AndroidUiState)]
struct Client {
ui_state: AndroidUiState,
info: WeakWidget<Text>,
}
impl AndroidAppState for Client {
type Resources = StdRsc<Self>;
fn on_insets_changed(&mut self, rsc: &mut Self::Resources, _: WindowInsets) {
let views = self
.ui_state
.renderer
.as_ref()
.map_or(0, |renderer| renderer.ui.view_count());
app::update_info(rsc, self.info, views);
}
}
#[iris::android_init]
fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc<Client>) -> Client {
let widgets = app::build(rsc, &mut ui_state);
app::update_info(rsc, widgets.info, 0);
Client {
ui_state,
info: widgets.info,
}
}
-30
View File
@@ -1,30 +0,0 @@
use iris::prelude::*;
use winit::event::WindowEvent;
#[path = "lib.rs"]
mod app;
#[derive(DesktopUiState)]
struct Client {
ui_state: DesktopUiState,
info: WeakWidget<Text>,
}
impl DesktopAppState for Client {
fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc<Self>) -> Self {
let widgets = app::build(rsc, &mut ui_state);
app::update_info(rsc, widgets.info, 0);
Self {
ui_state,
info: widgets.info,
}
}
fn window_event(&mut self, _: WindowEvent, rsc: &mut StdRsc<Self>) {
app::update_info(rsc, self.info, self.ui_state.renderer.ui.view_count());
}
}
fn main() {
DesktopApp::<Client>::run();
}
-224
View File
@@ -1,224 +0,0 @@
use iris::prelude::*;
use std::{cell::RefCell, rc::Rc};
pub(crate) fn update_info<Rsc: UiRsc>(rsc: &mut Rsc, info: WeakWidget<Text>, views: usize) {
let render_state = rsc.ui().render_state();
let new = format!(
"widgets: {}\nactive: {}\nviews: {views}",
rsc.widgets().len(),
render_state.get().active_widgets(),
);
if new != rsc.widgets()[info].content() {
rsc.widgets_mut()[info].set_text(new);
}
}
pub(crate) struct ClientWidgets {
pub(crate) info: WeakWidget<Text>,
}
pub(crate) fn build<Rsc: HasEvents>(
rsc: &mut Rsc,
ui_state: &mut impl HasRoot<Rsc>,
) -> ClientWidgets
where
Rsc::State: FocusHost,
{
let rrect = rect(PaintId::WHITE).radius(20);
let pad_test = (
rrect.clone().color(PaintId::BLUE),
(
rrect
.clone()
.color(PaintId::RED)
.sized((100, 100))
.center()
.width(rest(2)),
(
rrect.clone().color(PaintId::ORANGE),
rrect.clone().color(PaintId::LIME).pad(10.0),
)
.span(Dir::RIGHT)
.width(rest(2)),
rrect.clone().color(PaintId::YELLOW),
)
.span(Dir::RIGHT)
.pad(10)
.width(rest(3)),
)
.span(Dir::RIGHT)
.add(rsc);
let span_test = (
rrect.clone().color(PaintId::GREEN).width(100),
rrect.clone().color(PaintId::ORANGE),
rrect.clone().color(PaintId::CYAN),
rrect.clone().color(PaintId::BLUE).width(rel(0.5)),
rrect.clone().color(PaintId::MAGENTA).width(100),
rrect.color(PaintId::RED).width(100),
)
.span(Dir::LEFT)
.add(rsc);
let span_add = Span::empty(Dir::RIGHT).add(rsc);
let add_button = rect(PaintId::LIME)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
let child = image(include_bytes!("assets/sungals.png"))
.center()
.add_strong(rsc);
span_add(rsc).push(child);
})
.sized((150, 150))
.align(Align::BOT_RIGHT);
let del_button = rect(PaintId::RED)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
span_add(rsc).pop();
})
.sized((150, 150))
.align(Align::BOT_LEFT);
let span_add_test = (span_add, add_button, del_button).stack().add(rsc);
let btext = |content| wtext(content).size(30);
let text_test = (
btext("this is a").align(Align::LEFT),
btext("teeeeeeeest").align(Align::RIGHT),
btext("okkk\nokkkkkk!").align(Align::LEFT),
btext("hmm"),
btext("a"),
(
btext("'").family(MONOSPACE).align(Align::TOP),
btext("'").family(MONOSPACE),
btext(":gamer mode").family(MONOSPACE),
rect(PaintId::CYAN).sized((10, 10)).center(),
rect(PaintId::RED).sized((100, 100)).center(),
rect(PaintId::PURPLE).sized((50, 50)).align(Align::TOP),
)
.span(Dir::RIGHT)
.compact()
.center(),
wtext("pretty cool right?").size(50),
)
.span(Dir::DOWN)
.compact()
.add(rsc);
let texts = Span::empty(Dir::DOWN).gap(10).add(rsc);
let msg_area = texts
.scrollable(Axis::Y, Pin::Start)
.masked()
.background(rect(PaintId::SKY));
let add_text = wtext("add")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.size(30)
.attr::<Selectable>(())
.on(Submit, move |ctx, rsc: &mut Rsc| {
let w = ctx.widget;
let content = w(rsc).take();
let text = wtext(content)
.editable(EditMode::MultiLine)
.size(30)
.text_align(Align::LEFT)
.overflow(TextOverflow::Wrap)
.attr::<Selectable>(());
let fill = rsc
.ui_mut()
.paints
.add(Srgba8::WHITE.to_linear().darker(0.5));
let msg_box = text.background(rect(fill)).add_strong(rsc);
texts(rsc).push(msg_box);
})
.add(rsc);
let text_edit_scroll = (
msg_area.height(rest(1)),
(
Rect::new(
rsc.ui_mut()
.paints
.add(Srgba8::WHITE.to_linear().darker(0.9)),
),
(
add_text.width(rest(1)),
Rect::new(PaintId::GREEN)
.on(CursorSense::click(), move |ctx, rsc: &mut Rsc| {
rsc.run_event::<Submit>(add_text, (), ctx.state);
})
.sized((40, 40)),
)
.span(Dir::RIGHT)
.pad(10),
)
.stack()
.size(StackSize::Child(1))
.layer_offset(1)
.align(Align::BOT),
)
.span(Dir::DOWN)
.add(rsc);
let main = WidgetPtr::new().add(rsc);
let vals = Rc::new(RefCell::new((0, Vec::new())));
let mut switch_button = |solid: Srgba8, to: WeakWidget, label| {
let value = solid.to_linear();
let paint = rsc.ui_mut().paints.add(value);
let to = to.upgrade(rsc);
let vec = &mut vals.borrow_mut().1;
let i = vec.len();
if vec.is_empty() {
vec.push(None);
main(rsc).set(to);
} else {
vec.push(Some(to));
}
let vals = vals.clone();
let pressed = paint.clone();
let hovered = paint.clone();
let normal = paint.clone();
let rect = rect(paint)
.on(CursorSense::click(), move |_ctx, rsc: &mut Rsc| {
let (prev, vec) = &mut *vals.borrow_mut();
if let Some(h) = vec[i].take() {
vec[*prev] = main(rsc).replace(h);
*prev = i;
}
rsc.ui_mut().paints.set(&pressed, value.darker(0.3));
})
.on(
CursorSense::HoverStart | CursorSense::unclick(),
move |_ctx, rsc: &mut Rsc| {
rsc.ui_mut().paints.set(&hovered, value.brighter(0.2));
},
)
.on(CursorSense::HoverEnd, move |_ctx, rsc: &mut Rsc| {
rsc.ui_mut().paints.set(&normal, value);
})
.label(label);
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
};
let tabs = (
switch_button(Srgba8::RED, pad_test, "pad"),
switch_button(Srgba8::GREEN, span_test, "span"),
switch_button(Srgba8::BLUE, span_add_test, "image span"),
switch_button(Srgba8::MAGENTA, text_test, "text layout"),
switch_button(Srgba8::YELLOW, text_edit_scroll, "text edit scroll"),
)
.span(Dir::RIGHT);
let info = wtext("").add(rsc);
let info_sect = info.pad(10).align(Align::RIGHT);
((tabs.height(40), main.pad(10)).span(Dir::DOWN), info_sect)
.stack()
.set_root(rsc, ui_state);
ClientWidgets { info }
}
-12
View File
@@ -1,12 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[iris::android_init]
fn create(
ui_state: &mut AndroidUiState,
rsc: &mut StdRsc<AndroidUiState>,
) {
app::build(rsc, ui_state);
}
-8
View File
@@ -1,8 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
fn main() {
DesktopApp::run_with(app::build);
}
-22
View File
@@ -1,22 +0,0 @@
use iris::prelude::*;
use std::time::Duration;
pub(crate) fn build<Rsc: HasEvents + HasTasks>(rsc: &mut Rsc, ui_state: &mut impl HasRoot<Rsc>)
where
Rsc::State: FocusHost,
{
let rect = rect(PaintId::RED).add(rsc);
rect.label("Toggle color")
.task_on(CursorSense::click(), async move |mut ctx| {
iris::task::sleep(Duration::from_secs(1)).await;
ctx.update(move |_, rsc| {
let rect = rect(rsc);
if rect.is_paint(&PaintId::RED) {
rect.set_paint(PaintId::BLUE);
} else {
rect.set_paint(PaintId::RED);
}
});
})
.set_root(rsc, ui_state);
}
-9
View File
@@ -1,9 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[iris::android_init]
fn create(ui_state: &mut AndroidUiState, rsc: &mut StdRsc<AndroidUiState>) {
app::build(rsc, ui_state);
}
-8
View File
@@ -1,8 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
fn main() {
DesktopApp::run_with(app::build);
}
-156
View File
@@ -1,156 +0,0 @@
use iris::prelude::*;
const SAMPLE: &str = "The quick brown fox jumps over the lazy dog";
fn overflow_row<Rsc: UiRsc + 'static>(
rsc: &mut Rsc,
label: &str,
overflow: TextOverflow,
position: Len,
fill: PaintId,
) -> WeakWidget<Sized> {
(
wtext(label).size(14).color(PaintId::GRAY).width(dp(76)),
wtext(SAMPLE)
.size(20)
.overflow(overflow)
.overflow_position(position)
.width(dp(260))
.background(rect(fill)),
)
.span(Dir::RIGHT)
.gap(dp(8))
.width(dp(344))
.add(rsc)
}
pub(crate) fn build<Rsc: HasEvents + 'static>(rsc: &mut Rsc, ui_state: &mut impl HasRoot<Rsc>)
where
Rsc::State: FocusHost,
{
let panel = rsc
.ui_mut()
.paints
.add(Srgba8::new(34, 36, 42, 255).to_linear());
let field = rsc
.ui_mut()
.paints
.add(Srgba8::new(53, 57, 66, 255).to_linear());
let styled = "Bold, italic, underlined, and colored spans";
let styled = wtext(styled).size(20).spans(vec![
SpanStyle::new(0..4).bold(),
SpanStyle::new(6..12).italic(),
SpanStyle::new(14..24).underline(),
SpanStyle::new(30..37).color(PaintId::SKY),
]);
let aligned = (
wtext("Left aligned").text_align(Align::CENTER_LEFT),
wtext("Centered").text_align(Align::CENTER),
wtext("Right aligned").text_align(Align::CENTER_RIGHT),
)
.span(Dir::DOWN)
.gap(dp(4))
.width(rest(1))
.background(rect(field.clone()));
let wrapped = wtext(
"Wrapping shapes the same source into as many lines as its container needs. Resize the window to see it reflow.",
)
.overflow(TextOverflow::Wrap)
.size(18)
.width(dp(340))
.background(rect(field.clone()));
let editable = wtext(SAMPLE)
.overflow(TextOverflow::Ellipsis)
.editable(EditMode::SingleLine)
.size(20)
.attr::<Selectable>(())
.width(dp(344))
.background(rect(field.clone()));
let intro = (
wtext("Iris text")
.size(30)
.spans(vec![SpanStyle::new(0..9).bold()]),
wtext("Drag across display text to select it. The overflow markers select hidden source text, but are never copied.")
.overflow(TextOverflow::Wrap)
.color(PaintId::GRAY),
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let styles = (
wtext("Styles and alignment").size(16).color(PaintId::SKY),
styled,
aligned,
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let wrapping = (wtext("Wrapping").size(16).color(PaintId::SKY), wrapped)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let overflow = (
wtext("Overflow treatment and position")
.size(16)
.color(PaintId::SKY),
overflow_row(rsc, "hidden", TextOverflow::Hidden, rel(0), field.clone()),
overflow_row(
rsc,
"ellipsis 0",
TextOverflow::Ellipsis,
rel(0),
field.clone(),
),
overflow_row(
rsc,
"ellipsis .5",
TextOverflow::Ellipsis,
rel(0.5),
field.clone(),
),
overflow_row(
rsc,
"ellipsis 1",
TextOverflow::Ellipsis,
rel(1),
field.clone(),
),
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let editing = (
wtext("Editable ellipsis (move the caret through the text)")
.size(16)
.color(PaintId::SKY),
editable,
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let content = (intro, styles, wrapping, overflow, editing)
.span(Dir::DOWN)
.gap(dp(14))
.controller(SelectionController::new().separator("\n"))
.add(rsc);
content
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data;
rsc.with_nearest_controller::<SelectionController, _>(content, |id, selection, rsc| {
selection.drag(id, rsc, input)
});
})
.add(rsc);
content
.pad(dp(24))
.width(rest(1))
.background(rect(panel))
.set_root(rsc, ui_state);
}
-12
View File
@@ -1,12 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[iris::android_init]
fn create(
ui_state: &mut AndroidUiState,
rsc: &mut StdRsc<AndroidUiState>,
) {
app::build(rsc, ui_state);
}
-8
View File
@@ -1,8 +0,0 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
fn main() {
DesktopApp::run_with(app::build);
}
-36
View File
@@ -1,36 +0,0 @@
use iris::prelude::*;
#[derive(Clone, Copy, WidgetView)]
struct Test {
#[root]
root: WeakWidget<Rect>,
cur: WeakState<bool>,
}
impl Test {
pub fn new<State>(rsc: &mut StdRsc<State>) -> Self {
let root = rect(PaintId::RED).add(rsc);
let cur = rsc.create_state(root, false);
Self { root, cur }
}
pub fn toggle<State>(&self, rsc: &mut StdRsc<State>) {
let cur = &mut rsc[self.cur];
*cur = !*cur;
if *cur {
rsc[self.root].set_paint(PaintId::BLUE);
} else {
rsc[self.root].set_paint(PaintId::RED);
}
}
}
pub(crate) fn build<State>(rsc: &mut StdRsc<State>, ui_state: &mut impl HasRoot<StdRsc<State>>)
where
State: FocusHost,
{
let test = Test::new(rsc);
test.on(CursorSense::click(), move |_, rsc| {
test.toggle(rsc);
})
.set_root(rsc, ui_state);
}
+3 -3
View File
@@ -4,9 +4,9 @@ version.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
proc-macro2 = "1.0.107" proc-macro2 = "1.0.103"
quote = "1.0.47" quote = "1.0.42"
syn = { version = "3.0.5", features = ["full"] } syn = { version = "2.0.111", features = ["full"] }
[lib] [lib]
proc-macro = true proc-macro = true
+80 -246
View File
@@ -2,115 +2,13 @@ extern crate proc_macro;
use proc_macro::TokenStream; use proc_macro::TokenStream;
use quote::quote; use quote::quote;
use syn::{ use syn::{
Attribute, Block, Error, FnArg, GenericParam, Generics, Ident, ItemFn, ItemStruct, ItemTrait, Attribute, Block, Error, Fields, FieldsNamed, GenericParam, Generics, Ident, ItemStruct,
ReturnType, Signature, Token, Type, Visibility, ItemTrait, Meta, Signature, Token, Visibility,
parse::{Parse, ParseStream, Result}, parse::{Parse, ParseStream, Result},
parse_macro_input, parse_quote, parse_macro_input, parse_quote,
spanned::Spanned, spanned::Spanned,
}; };
/// Marks the initializer called when Android creates an Iris view.
///
/// An attribute is necessary here because the Android loader requires one
/// exported `JNI_OnLoad` symbol and `android-view` requires a plain function
/// pointer monomorphized for the application state. A function returning a
/// custom state remains its factory; a function with no return value receives
/// `&mut AndroidUiState` and uses that state directly. The generated linker and
/// JNI glue is Android-gated; the annotated function therefore does not need
/// its own `cfg` attribute.
#[proc_macro_attribute]
pub fn android_init(args: TokenStream, item: TokenStream) -> TokenStream {
if !args.is_empty() {
return Error::new(
proc_macro2::Span::call_site(),
"android_init takes no arguments",
)
.into_compile_error()
.into();
}
let function = parse_macro_input!(item as ItemFn);
let name = &function.sig.ident;
let (state, direct_initializer): (Type, bool) = match &function.sig.output {
ReturnType::Default => (parse_quote!(::iris::android::AndroidUiState), true),
ReturnType::Type(_, state) => ((**state).clone(), false),
};
if function.sig.inputs.len() != 2
|| function
.sig
.inputs
.iter()
.any(|argument| !matches!(argument, FnArg::Typed(_)))
{
return Error::new(
function.sig.inputs.span(),
"an android_init function takes UI state and resources",
)
.into_compile_error()
.into();
}
if function.sig.asyncness.is_some()
|| function.sig.constness.is_some()
|| matches!(function.sig.safety, syn::Safety::Unsafe(_))
|| !function.sig.generics.params.is_empty()
{
return Error::new(
function.sig.span(),
"an android_init function must be a plain, non-generic synchronous function",
)
.into_compile_error()
.into();
}
let factory = if direct_initializer {
quote! {
fn init(
mut ui_state: ::iris::android::AndroidUiState,
rsc: &mut <#state as ::iris::android::AndroidAppState>::Resources,
) -> #state {
super::#name(&mut ui_state, rsc);
ui_state
}
}
} else {
quote! {}
};
let create = if direct_initializer {
quote! { init }
} else {
quote! { super::#name }
};
quote! {
#[cfg(target_os = "android")]
#function
#[cfg(target_os = "android")]
mod __iris_android_app {
use super::*;
#factory
extern "system" fn new_view_peer<'local>(
env: ::iris::android::__private::JNIEnv<'local>,
view: ::iris::android::__private::View<'local>,
context: ::iris::android::__private::Context<'local>,
) -> ::iris::android::__private::JLong {
::iris::android::new_peer::<#state>(env, view, context, #create)
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(
vm: *mut ::iris::android::__private::RawJavaVM,
_: *mut ::core::ffi::c_void,
) -> ::iris::android::__private::JInt {
unsafe { ::iris::android::__private::on_load(vm, new_view_peer) }
}
}
}
.into()
}
struct Input { struct Input {
attrs: Vec<Attribute>, attrs: Vec<Attribute>,
vis: Visibility, vis: Visibility,
@@ -120,7 +18,6 @@ struct Input {
} }
struct InputFn { struct InputFn {
attrs: Vec<Attribute>,
sig: Signature, sig: Signature,
body: Block, body: Block,
} }
@@ -135,10 +32,9 @@ impl Parse for Input {
input.parse::<Token![;]>()?; input.parse::<Token![;]>()?;
let mut fns = Vec::new(); let mut fns = Vec::new();
while !input.is_empty() { while !input.is_empty() {
let attrs = input.call(Attribute::parse_outer)?;
let sig = input.parse()?; let sig = input.parse()?;
let body = input.parse()?; let body = input.parse()?;
fns.push(InputFn { attrs, sig, body }) fns.push(InputFn { sig, body })
} }
if !input.is_empty() { if !input.is_empty() {
input.error("function expected"); input.error("function expected");
@@ -163,13 +59,10 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
fns, fns,
} = parse_macro_input!(input as Input); } = parse_macro_input!(input as Input);
let sigs: Vec<_> = fns let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect();
.iter()
.map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig })
.collect();
let impls: Vec<_> = fns let impls: Vec<_> = fns
.iter() .iter()
.map(|InputFn { sig, body, .. }| quote! { #sig #body }) .map(|InputFn { sig, body }| quote! { #sig #body })
.collect(); .collect();
let Some(GenericParam::Type(state)) = generics.params.first() else { let Some(GenericParam::Type(state)) = generics.params.first() else {
@@ -196,163 +89,104 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
quote! { quote! {
#trai #trai
impl #generics #name<Rsc, WL, Tag> for WL { impl #generics #name<State, WL, Tag> for WL {
#(#impls)* #(#impls)*
} }
} }
.into() .into()
} }
#[proc_macro_derive(DesktopUiState, attributes(desktop_ui_state))] #[proc_macro_derive(UiState, attributes(rsc))]
pub fn derive_desktop_ui_state(input: TokenStream) -> TokenStream { pub fn derive_ui_state(input: TokenStream) -> TokenStream {
let state: ItemStruct = parse_macro_input!(input);
derive_ui_state(
state,
UiStateDerive {
module: "desktop",
state_type: "DesktopUiState",
state_trait: "HasDesktopUiState",
field_attr: "desktop_ui_state",
get: "desktop_state",
get_mut: "desktop_state_mut",
},
)
}
#[proc_macro_derive(AndroidUiState, attributes(android_ui_state))]
pub fn derive_android_ui_state(input: TokenStream) -> TokenStream {
let state: ItemStruct = parse_macro_input!(input);
derive_ui_state(
state,
UiStateDerive {
module: "android",
state_type: "AndroidUiState",
state_trait: "HasAndroidUiState",
field_attr: "android_ui_state",
get: "android_state",
get_mut: "android_state_mut",
},
)
}
struct UiStateDerive {
module: &'static str,
state_type: &'static str,
state_trait: &'static str,
field_attr: &'static str,
get: &'static str,
get_mut: &'static str,
}
fn derive_ui_state(state: ItemStruct, names: UiStateDerive) -> TokenStream {
let UiStateDerive {
module,
state_type,
state_trait,
field_attr,
get,
get_mut,
} = names;
let mut output = proc_macro2::TokenStream::new(); let mut output = proc_macro2::TokenStream::new();
let mut found_attr = false;
let mut state_field = None; let state: ItemStruct = parse_macro_input!(input);
for field in &state.fields { let sname = state.ident;
if !found_attr let rscname = Ident::new(&(sname.to_string() + "Rsc"), sname.span());
&& let Type::Path(path) = &field.ty let mut rsc_fields = Vec::new();
&& path.path.is_ident(state_type)
{ for field in state.fields {
state_field = Some(field); let Some(attr) = field.attrs.iter().find(|a| a.path().is_ident("rsc")) else {
}
let Some(attr) = field.attrs.iter().find(|a| a.path().is_ident(field_attr)) else {
continue; continue;
}; };
if found_attr { let Meta::List(list) = &attr.meta else {
output.extend( output.extend(Error::new(attr.span(), "invalid attr syntax").into_compile_error());
Error::new( continue;
attr.span(), };
format!("cannot have more than one {field_attr} attribute"), let tname: Ident = match list.parse_args::<Ident>() {
) Ok(ident) => ident,
.into_compile_error(), Err(err) => {
); output.extend(err.to_compile_error());
continue; continue;
} }
found_attr = true;
state_field = Some(field);
}
let Some(field) = state_field else {
output.extend(
Error::new(state.ident.span(), format!("no {state_type} field found"))
.into_compile_error(),
);
return output.into();
}; };
let sname = &state.ident; let fty = &field.ty;
let Some(fname) = field.ident.as_ref() else { let fname = &field.ident.unwrap();
return Error::new( rsc_fields.extend(quote! {#fname: #fty,});
field.span(),
format!("the {state_type} field must be named"),
)
.into_compile_error()
.into();
};
let module = Ident::new(module, sname.span());
let state_type = Ident::new(state_type, sname.span());
let state_trait = Ident::new(state_trait, sname.span());
let get = Ident::new(get, sname.span());
let get_mut = Ident::new(get_mut, sname.span());
let (impl_generics, type_generics, where_clause) = state.generics.split_for_impl();
output.extend(quote! { output.extend(quote! {
impl #impl_generics iris::#module::#state_trait for #sname #type_generics #where_clause { impl #tname for #sname {
fn #get(&self) -> &iris::#module::#state_type { fn get(&self) -> &#fty {
&self.#fname &self.#fname
} }
fn #get_mut(&mut self) -> &mut iris::#module::#state_type { fn get_mut(&mut self) -> &mut #fty {
&mut self.#fname
}
}
impl #tname for #rscname {
fn get(&self) -> &#fty {
&self.#fname
}
fn get_mut(&mut self) -> &mut #fty {
&mut self.#fname &mut self.#fname
} }
} }
}); });
output.into()
}
#[proc_macro_derive(WidgetView, attributes(root))]
pub fn derive_widget_view(input: TokenStream) -> TokenStream {
let mut output = proc_macro2::TokenStream::new();
let state: ItemStruct = parse_macro_input!(input);
let mut found_attr = false;
let mut state_field = None;
for field in &state.fields {
let Some(attr) = field.attrs.iter().find(|a| a.path().is_ident("root")) else {
continue;
};
if found_attr {
output.extend(
Error::new(attr.span(), "cannot have more than one root widget")
.into_compile_error(),
);
continue;
} }
found_attr = true; let vis = state.vis;
state_field = Some(field);
}
let Some(field) = state_field else {
output.extend(
Error::new(state.ident.span(), "no root widget field found (#[root])")
.into_compile_error(),
);
return output.into();
};
let sname = &state.ident;
let fname = field.ident.as_ref().unwrap();
let fty = &field.ty;
output.extend(quote! { output.extend(quote! {
impl iris::core::WidgetView for #sname { #vis struct #rscname {
type Widget = <#fty as iris::core::HasWidget>::Widget; #(#rsc_fields)*
fn root(&self) -> #fty { }
self.#fname
impl HasState for #sname {
type State = #sname;
}
impl HasState for #rscname {
type State = #sname;
}
impl StateLike<#sname> for #sname {
fn as_state(&mut self) -> &mut Self {
self
}
}
impl StateLike<#rscname> for #rscname {
fn as_state(&mut self) -> &mut Self {
self
} }
} }
}); });
output.into() output.into()
} }
#[proc_macro_attribute]
pub fn default_ui_state(_attr: TokenStream, input: TokenStream) -> TokenStream {
let mut state: ItemStruct = parse_macro_input!(input);
let Fields::Named(fields) = &mut state.fields else {
panic!("must be on named fields struct");
};
let name = &state.ident;
state.attrs.push(parse_quote! {#[derive(UiState)]});
let new: FieldsNamed = parse_quote! {{
#[rsc(HasUi)]
pub ui: Ui,
#[rsc(HasDefaultUiState)]
pub ui_state: DefaultUiState,
#[rsc(HasEvents)]
pub events: iris::prelude::EventManager<#name>,
}};
fields.named.extend(new.named);
quote! {#state}.into()
}
+2 -101
View File
@@ -4,111 +4,12 @@ My experimental attempt at a rust ui library (also my first ui library).
It's currently designed around using retained data structures (widgets), rather than diffing generated trees from data like xilem or iced. This is an experiment and I'm not sure if it's a good idea or not. It's currently designed around using retained data structures (widgets), rather than diffing generated trees from data like xilem or iced. This is an experiment and I'm not sure if it's a good idea or not.
Examples are in `examples`, eg. `cargo run --example tabs`. Each example keeps There's a `main.rs` that runs a testing window, so you can just `cargo run` to see it working.
its widget tree in `lib.rs` and its small desktop and Android hosts in
`desktop.rs` and `android.rs`.
## Android applications
An Android application is a library because Android loads its Rust code as a
native shared library:
```toml
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
iris = { path = "../iris" }
[package.metadata.iris.android]
application-id = "com.example.myapp"
label = "My app"
```
`#[iris::android_init]` marks the initializer called when Android creates the Iris
view. The attribute supplies its own Android target gate and generates the JNI
loader glue. An application with no state beyond the UI state receives
`AndroidUiState` directly by mutable reference:
```rust
use iris::prelude::*;
fn build<Rsc: UiRsc>(rsc: &mut Rsc, ui_state: &mut impl HasRoot<Rsc>) {
rect(PaintId::RED).set_root(rsc, ui_state);
}
#[iris::android_init]
fn create(
ui_state: &mut AndroidUiState,
rsc: &mut StdRsc<AndroidUiState>,
) {
build(rsc, ui_state);
}
```
Desktop has the corresponding initializer form:
```rust
DesktopApp::run_with(build);
```
Background work updates either host through the same task context. An update
wakes the UI thread; Iris schedules a frame automatically if the closure made
the retained widget tree dirty:
```rust
rsc.spawn_task(async move |mut ctx| {
let text = load_text().await;
ctx.update(move |_, rsc| label(rsc).set(&text));
});
```
Applications do not need a winit event proxy or an explicit redraw request.
Applications with additional fields use their own state type. Its
`AndroidAppState::Resources` associated type can also replace `StdRsc` with a
custom resource bundle.
Install the Cargo subcommand from a checkout, then invoke it from the
application's directory:
```sh
cargo install --path /path/to/iris/cargo-iris
cargo iris apk --abi arm64-v8a
cargo iris run --abi x86_64 --device emulator-5554
```
Package examples use the same command with `--example`:
```sh
cargo run --example tabs
cargo iris apk --example tabs --abi arm64-v8a
cargo iris run --example tabs --abi x86_64 --device emulator-5554
```
`cargo iris` packages directly with `cargo-ndk`, `javac`, `d8`, `aapt2`,
`jar`, `zipalign`, and `apksigner`; it does not require Gradle. The caller
provides a JDK, Android SDK and NDK, and any emulator or physical device. Set
`ANDROID_HOME` to the SDK. `run` always requires an explicit device and never
creates or starts one.
Debug APKs use the standard key at `~/.android/debug.keystore`, creating it
with `keytool` when absent. A release build requires the long-lived signing
identity explicitly:
```sh
IRIS_KEYSTORE_PASSWORD=... IRIS_KEY_PASSWORD=... \
cargo iris apk --release --keystore /secure/upload.jks --key-alias upload
```
The verified APK lives under
`target/iris-android/<package>[-<example>]/<debug|release>/<abi>/`; packaging
intermediates are removed before the command prints its absolute path.
Goals, in general order: Goals, in general order:
1. does what I want it to (text, images, video, animations) 1. does what I want it to (text, images, video, animations)
2. very easy to use ignoring ergonomic ref counting 2. very easy to use ignoring ergonomic ref counting
3. reasonably fast / efficient (a lot faster than electron, save battery life, try to beat iced and xilem) 3. reasonably fast / efficient (a lot faster than electron, save battery life)
## dev details ## dev details
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "rig-input"
version.workspace = true
edition.workspace = true
# Replays harness `.touch` files through Wayland's virtual-pointer protocol;
# headless sway has no input devices for coordinate-driving tools to move.
[[bin]]
name = "replay-touch"
path = "src/main.rs"
[dependencies]
# Share the harness parser so both layers interpret recordings identically.
iris = { path = ".." }
wayland-client = "0.31.15"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
-141
View File
@@ -1,141 +0,0 @@
use iris::harness::{TouchAction, TouchScript};
use std::time::Duration;
use wayland_client::protocol::wl_pointer::ButtonState;
use wayland_client::protocol::{wl_registry, wl_seat};
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
const BTN_LEFT: u32 = 0x110;
const SETTLE: Duration = Duration::from_millis(200);
#[derive(Default)]
struct Globals {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwlrVirtualPointerManagerV1>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_virtual_pointer_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => {}
}
}
}
delegate_noop!(Globals: ignore wl_seat::WlSeat);
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
delegate_noop!(Globals: ZwlrVirtualPointerV1);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let [width, height, path] = args.as_slice() else {
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
std::process::exit(2);
};
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
fail(&format!(
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
))
});
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let display = conn.display();
display.get_registry(&qh, ());
let mut globals = Globals::default();
queue
.roundtrip(&mut globals)
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
let manager = globals.manager.as_ref().unwrap_or_else(|| {
fail(
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
be synthesised; sway and every wlroots compositor do",
)
});
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
// Put the pointer where the gesture starts and let the compositor
// settle before anything is pressed. Without this the press is
// dropped: sway has just learned about this pointer, and a button
// sent in the same breath as the motion that first puts it over a
// window arrives before there is a focused surface to send it to --
// winit sees `CursorEntered`, the moves and the *release*, never the
// press, so the gesture reads as a hover and nothing scrolls. Found
// by printing winit's own events; the settle is what fixed it.
if let Some(first) = script.samples.first() {
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
pointer.frame();
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
std::thread::sleep(SETTLE);
}
let mut previous = 0;
for sample in &script.samples {
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
previous = sample.t_ms;
let t = sample.t_ms as u32;
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
pointer.frame();
// The button goes in a frame of its own, *after* the motion has
// been committed. Sent in the same frame as the motion that
// first puts the pointer over the window, sway drops it: the
// client sees `CursorEntered` and the moves but never a
// `MouseInput { state: Pressed }`, so the whole gesture reads as
// a hover and nothing scrolls. Found exactly that way, by
// printing winit's events.
let state = match sample.action {
TouchAction::Down => Some(ButtonState::Pressed),
TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released),
TouchAction::Move => None,
};
if let Some(state) = state {
pointer.button(t, BTN_LEFT, state);
pointer.frame();
}
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
}
pointer.destroy();
conn.flush().ok();
}
fn parse(text: &str, what: &str) -> u32 {
text.parse()
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
}
fn fail(message: &str) -> ! {
eprintln!("replay-touch: {message}");
std::process::exit(1);
}
-4
View File
@@ -1,4 +0,0 @@
[toolchain]
channel = "nightly"
components = ["clippy", "rustfmt"]
targets = ["aarch64-linux-android", "x86_64-linux-android"]
-14
View File
@@ -1,14 +0,0 @@
# The compositor `scripts/run-headless.sh` starts, because this machine has no
# display. Nothing here is meant to be looked at directly; `grim` is.
#
# No Xwayland: winit talks Wayland natively, and starting an X server is a
# second thing to go wrong for no gain. (`emu`'s config forces it because the
# Android emulator's renderer speaks GLX.)
xwayland disable
# A desktop-shaped output, since this is the desktop half of the port. Larger
# than the window an example opens, so nothing is scaled or clipped.
output HEADLESS-1 mode 1920x1200@60Hz
default_border none
focus_follows_mouse no
-151
View File
@@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""AOSP's fling spline, transcribed independently of the Rust port.
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
grading its own homework. Every test iris's fling had before 2026-09-07
compared the curve with itself -- monotonic, signed, integrates to the closed
form -- and all of them passed while `distance_fraction(t)` was returning
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
test have to come from somewhere else, and this is the somewhere else.
Transcribed by hand from, and only from:
* frameworks/base `core/java/android/widget/OverScroller.java`,
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
line, which is why iris ports one curve rather than two.
Run it with no arguments; it prints the table entries and the (velocity,
density, t) points the Rust tests assert on.
"""
NB_SAMPLES = 100
INFLEXION = 0.35
START_TENSION = 0.5
END_TENSION = 1.0
P1 = START_TENSION * INFLEXION
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
SCROLL_FRICTION = 0.015
TUNING = 0.84
GRAVITY_EARTH = 9.80665
INCHES_PER_METER = 39.37
import math
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
def spline_positions():
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
position = [0.0] * (NB_SAMPLES + 1)
x_min = 0.0
for i in range(NB_SAMPLES):
alpha = i / NB_SAMPLES
x_max = 1.0
while True:
x = x_min + (x_max - x_min) / 2.0
coef = 3.0 * x * (1.0 - x)
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
if abs(tx - alpha) < 1e-5:
break
if tx > alpha:
x_max = x
else:
x_min = x
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
position[NB_SAMPLES] = 1.0
return position
POSITION = spline_positions()
def fling_sample(t):
"""(distance fraction, velocity fraction) at time fraction `t`."""
t = min(max(t, 0.0), 1.0)
index = int(t * NB_SAMPLES)
if index >= NB_SAMPLES:
return 1.0, 0.0
t_inf = index / NB_SAMPLES
t_sup = (index + 1) / NB_SAMPLES
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
def physical_coefficient(density):
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
def deceleration(velocity, density):
return math.log(
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
)
def fling_distance(velocity, density):
l = deceleration(velocity, density)
return (
SCROLL_FRICTION
* physical_coefficient(density)
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
)
def fling_duration_s(velocity, density):
l = deceleration(velocity, density)
return math.exp(l / (DECELERATION_RATE - 1.0))
def position_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
def velocity_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
if __name__ == "__main__":
print("SPLINE_POSITION at a few indices (index: value)")
for i in (0, 1, 10, 25, 50, 75, 99, 100):
print(f" {i:3}: {POSITION[i]:.6f}")
print()
print("distance/velocity fraction at time fractions")
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
d, v = fling_sample(t)
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
print()
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
# 2.75 is this checkout's emulator.
for density in (2.55, 2.75):
# 15250 is `app/touch/flick-120hz.touch`'s own
# release velocity (velocity_reference.py), so `phone_screen.rs`
# can bound the fling it produces from *here* rather than from the
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
for velocity in (5000.0, 11064.0, 15250.0):
dur = fling_duration_s(velocity, density)
print(
f"density={density} v={velocity}: "
f"distance={fling_distance(velocity, density):.3f}px "
f"duration={dur:.4f}s"
)
# Deliberately not round fractions. The velocity coefficient is
# piecewise *constant* across each of the 100 samples, so it
# steps at t = k/100 and a test asserting on 0.75 is asserting
# on which side of a discontinuity the last float landed --
# which is genuinely different between Python and Rust and says
# nothing about the curve.
for frac in (0.125, 0.335, 0.505, 0.755):
t = frac * dur
print(
f" t={frac:>4} of duration ({t:.4f}s): "
f"pos={position_at(velocity, density, t):.3f}px "
f"vel={velocity_at(velocity, density, t):.3f}px/s"
)
-288
View File
@@ -1,288 +0,0 @@
#!/usr/bin/env python3
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
Same reason `fling_spline_reference.py` exists: the numbers checked into
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
estimator -- total motion over the sample span, an average -- passed every test
it had, because every one of those tests asserted the average's own definition
back at it. An average cannot tell an accelerating flick from a steady drag, and
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
actually works but is slower than Compose's immediately after releasing the
flick".
Transcribed by hand from, and only from, the `-sources.jar` of
**androidx.compose.ui:ui-android:1.12.0** and
**androidx.compose.foundation:foundation-android:1.12.0**
(dl.google.com/dl/android/maven2), read 2026-09-07:
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
`HistorySize = 20`, `HorizonMilliseconds = 100`,
`AssumePointerMoveStoppedMilliseconds = 40`.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
default, which is `false`.
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
feeds the tracker and where the maximum-velocity clamp is applied.
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
* `androidx/compose/foundation/gestures/Scrollable.kt` --
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
**Which strategy a touch fling actually uses, since this was the surprise.**
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
least-squares fit over **absolute positions**, whose velocity is the fitted
polynomial's derivative at the newest sample. Impulse is reached only through
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
printed points, because ruling it out by reading is cheaper than ruling it out
again next time somebody remembers "Compose uses impulse".
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
and all the UP branch does is reset the tracker when more than 40ms have passed
since the last MOVE (b/238654963). So a finger that stops before lifting reads
as a stop, not as a decelerating tail. Positions are the raw event positions,
so the touch slop is inside the motion the tracker sees even though the list
never scrolled by it.
Two of Compose's samples iris does *not* reproduce, both noted rather than
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
one, since it is a real measured position and dropping it would be copying a
quirk of where Compose happens to split its state machine.
**The clamps.** Maximum: `sendDragStopped` passes
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
exists in Compose's `ViewConfiguration` interface but its only use in either
artifact is `NestedScrollInteropConnection`, for View interop.
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
and says why in its own comment: "we need it since spline curve gives us
NaNs". 1 px/s, not 50 dp/s.
Run it with no arguments; it prints the sample sets and the velocities the
Rust tests assert on.
"""
import math
HISTORY_SIZE = 20
HORIZON_MILLISECONDS = 100.0
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
MIN_SAMPLE_SIZE_LSQ2 = 3
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
# DefaultFlingBehavior.performFling's own threshold, in the units of the
# positions fed to the tracker -- pixels per second here.
FLING_MINIMUM_PX_S = 1.0
def poly_fit_least_squares(x, y, sample_count, degree):
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
if degree < 1:
raise ValueError("The degree must be at positive integer")
if sample_count == 0:
raise ValueError("At least one point must be provided")
truncated_degree = sample_count - 1 if degree >= sample_count else degree
m = sample_count
n = truncated_degree + 1
a = [[0.0] * m for _ in range(n)]
for h in range(m):
a[0][h] = 1.0
for i in range(1, n):
a[i][h] = a[i - 1][h] * x[h]
q = [[0.0] * m for _ in range(n)]
r = [[0.0] * n for _ in range(n)]
for j in range(n):
w = q[j]
w[:] = a[j][:m]
for i in range(j):
z = q[i]
dot = sum(w[h] * z[h] for h in range(m))
for h in range(m):
w[h] -= dot * z[h]
norm = math.sqrt(sum(v * v for v in w))
inverse_norm = 1.0 / max(norm, 1e-6)
for h in range(m):
w[h] *= inverse_norm
for i in range(n):
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
coefficients = [0.0] * n
for i in range(n - 1, -1, -1):
c = sum(q[i][h] * y[h] for h in range(m))
for j in range(n - 1, i, -1):
c -= r[i][j] * coefficients[j]
coefficients[i] = c / r[i][i]
return coefficients
def kinetic_energy_to_velocity(kinetic_energy):
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
return sign * math.sqrt(2 * abs(kinetic_energy))
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
work = 0.0
start = sample_count - 1
next_time = time[start]
for i in range(start, 0, -1):
current_time = next_time
next_time = time[i - 1]
if current_time == next_time:
continue
if is_data_differential:
delta = -data_points[i - 1]
else:
delta = data_points[i] - data_points[i - 1]
v_curr = delta / (current_time - next_time)
v_prev = kinetic_energy_to_velocity(work)
work += (v_curr - v_prev) * abs(v_curr)
if i == start:
work = work * 0.5
return kinetic_energy_to_velocity(work)
def calculate_velocity(samples):
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
`samples` is `(time_millis, position)` oldest first, at most the last
`HISTORY_SIZE` of which the circular buffer would still be holding.
Returns units per second.
"""
held = samples[-HISTORY_SIZE:]
if not held:
return 0.0
data_points = []
time = []
newest_time, _ = held[-1]
previous_time = newest_time
for sample_time, sample_position in reversed(held):
age = float(newest_time - sample_time)
delta = abs(float(sample_time - previous_time))
# Lsq2 walks back sample to sample; only the non-differential
# Impulse branch compares every sample against the newest one.
previous_time = sample_time
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
break
data_points.append(sample_position)
time.append(-age)
if len(data_points) == HISTORY_SIZE:
break
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
return 0.0
try:
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
except ValueError:
return 0.0
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
# which is the newest sample's timestamp. units/ms -> units/s.
return coefficients[1] * 1000.0
def clamped(velocity, maximum):
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
if velocity == 0.0 or math.isnan(velocity):
return 0.0
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
def average(samples):
"""The estimator being replaced: total motion over the span."""
if len(samples) < 2:
return 0.0
span = (samples[-1][0] - samples[0][0]) / 1000.0
if span <= 0.0:
return 0.0
return (samples[-1][1] - samples[0][1]) / span
# 1. `app/touch/flick-120hz.touch`, as `DragGesture` feeds it:
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
# sample (see the module doc), which is why the finger sitting still for its
# last 4ms does not drag the estimate down. y only; the flick is vertical.
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
# average must agree here -- this is the case that cannot tell the two
# estimators apart, which is why it is not the only one.
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
# This is the case the average gets wrong, and the negative control for
# the port -- reverting to the average must fail this test and only this
# kind of test.
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
# 4. The two edges of the sample walk, checked here so the Rust asserts
# Compose's answer rather than iris's own reading of the rule.
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
# drag: the burst must not leak into the estimate.
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
# ... and one move frame, which Compose cannot fit either.
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
PHONE_DENSITY = 2.55
def report(name, samples):
v = calculate_velocity(samples)
print(f"{name}:")
print(f" samples (t_ms, position): {samples}")
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
print(f" average (the old estimator): {average(samples):.4f} px/s")
print(f" impulse (non-touch, for ref): ", end="")
held = list(reversed(samples[-HISTORY_SIZE:]))
newest = held[0][0]
print(
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
)
print()
if __name__ == "__main__":
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
report("flick-120hz.touch", FLICK_120HZ)
report("steady drag (5px/10ms)", STEADY_DRAG)
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
report("press and two move frames", TWO_MOVE_FRAMES)
report("press and one move frame", ONE_MOVE_FRAME)
print("Clamps:")
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
print(
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
)
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
print()
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
-94
View File
@@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
from a report the layer-1 harness produced with tracing on
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
`iris::harness::Harness::replay` can play back at layer 1.
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
layer that can answer a question wins, and a gesture that misbehaves on
Iris's phone is otherwise only describable in words. `iris::sense::
log_input_event`'s one line per platform event (Android's on_touch_event
once per `MotionEvent`, with historical samples inline; winit's once per
pointer `WindowEvent`; the harness's `touch`, once per script line) already
carries everything a `.touch` file's `t_ms action x y` needs -- this just
reads it back out and reconstructs the samples in order, expanding each
event's inline historical samples into their own `move` lines first (they
are always intermediate positions of a move, and Android documents them as
oldest first, which is also the order they appear in the line).
Usage:
report_to_touch.py < report.txt > replay.touch
report_to_touch.py report.txt > replay.touch
Only lines containing "iris input: action=..." are read; everything else in
the report (insets, frame timings, drag-release summaries) is ignored, so
this can be pointed at Copy report's whole clipboard text directly.
"""
import re
import sys
# The message half of `sense::log_input_event`'s format string, prefix-
# agnostic: a real report line also carries the ring's own
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
# of that -- neither of which this needs to understand, since `search`
# (not `match`) finds the marker wherever it starts.
LINE_RE = re.compile(
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
)
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
def _fmt(value: float) -> str:
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
it -- an integer without a trailing `.0` where the source was one
(every coordinate here is a physical pixel), `{:g}` otherwise so a
fractional value from a real device is not silently truncated."""
if value == int(value):
return str(int(value))
return f"{value:g}"
def convert(lines):
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
action, x, y)` tuple per touch sample -- a historical sample is always
an intermediate `move`, and the event's own sample keeps its real
action (`down`/`move`/`up`/`cancel`)."""
rows = []
for line in lines:
m = LINE_RE.search(line)
if not m:
continue
hist_count = int(m.group("hist"))
hist_matches = list(HIST_RE.finditer(m.group("rest")))
if len(hist_matches) != hist_count:
print(
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
f"holds {len(hist_matches)} samples -- skipped",
file=sys.stderr,
)
continue
for hm in hist_matches:
rows.append(
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
)
rows.append(
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
)
return rows
def main():
if len(sys.argv) > 2:
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
return 2
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
for t_ms, action, x, y in convert(text):
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
return 0
if __name__ == "__main__":
sys.exit(main())
-25
View File
@@ -1,25 +0,0 @@
#!/bin/sh
# Runs iris's on-demand benchmark suite.
# Never run by `cargo test`; run this by hand or before/after a layout
# change. Always release -- see AGENTS.md's own rule against reading a
# frame time from a debug build.
#
# ./scripts/run-bench.sh # everything
# ./scripts/run-bench.sh list # just the CPU-only message-list scenarios
# ./scripts/run-bench.sh images # just the GPU bind-group-creation scenario
set -eu
scripts=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$scripts/.." && pwd)
cd "$root"
what="${1:-all}"
if [ "$what" = "all" ] || [ "$what" = "list" ]; then
echo "=== message_list (CPU-only, no window) ==="
cargo bench --bench message_list
fi
if [ "$what" = "all" ] || [ "$what" = "images" ]; then
echo "=== bench_images (real wgpu device, via run-headless.sh) ==="
timeout 60 "$scripts/run-headless.sh" bench_images --seconds 4 2>&1 | grep "^BENCH_IMAGES"
fi
-198
View File
@@ -1,198 +0,0 @@
#!/bin/sh
# Run an iris example on this machine, which has no display.
#
# ./scripts/run-headless.sh tabs [-- cargo args]
# ./scripts/run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
# ./scripts/run-headless.sh phone --phone --dir ../app --shot /tmp/p.png
# ./scripts/run-headless.sh phone --phone --dir ../app \
# --replay ../app/touch/flick-120hz.touch --shot /tmp/p.png
#
# `--dir DIR` names the workspace to build in, defaulting to `iris/`. The
# app's examples -- the phone-sized transcript
# screen and everything else that is about *this product* -- live in
# `app/`, which is a workspace of its own; `replay-touch` is still
# built from iris, since it is part of the rig rather than of either app.
#
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
# and the window take Iris's phone's own size and density (1080x2424 at
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
# carried in `ai_app::ui::fixture::PHONE_*`), and `IRIS_SCALE` hands that
# density to iris the way `DisplayMetrics.density` does on Android
# (`iris::desktop::content_scale`). So a screenshot from here and one
# from the phone are the same layout at the same density, and what
# differs is only the renderer. Without it the output stays desktop-
# shaped, which is what every other example wants.
#
# `--replay FILE` drives one of the `.touch` recordings the headless
# tests use (`app/touch/`) into the window through
# `rig-input`'s `replay-touch` -- one recording, both layers. With
# `--shot` it also writes `<shot>-before.png` from just before the
# gesture, since "the list moved" is a claim about two pictures.
#
# `--bin` runs a real crate binary instead of an example (E4's
# `ai-app-desktop`, which is a window a person runs, not a demo) --
# `cargo build --bin NAME` instead of `--example NAME`, and
# `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own
# argv (the CLI flags a real binary takes, as opposed to `cargo build`'s
# own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on
# purpose -- an example never needed one, so there was nowhere to plumb it
# through positionally without disturbing the existing `-- cargo args`
# convention above.
#
# The VM has a real GPU and no display (the `this-machine-graphics` skill
# says what it is and how it fails), so what is missing here is only a
# compositor to give winit a surface. So: a headless sway, the same trick
# `emu` uses for the Android emulator, and `grim` to see the result.
#
# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window
# to the one an emulator is sitting in resizes that emulator's window, and a
# peer session's `emu up` could join at any moment. This one has its own
# socket and its own runtime directory and goes away with the machine.
set -eu
scripts=$(cd "$(dirname "$0")" && pwd)
root=$(cd "$scripts/.." && pwd)
workdir="$root"
cd "$root"
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3
shot=""
replay=""
example=""
kind=example
phone=no
# The phone Iris runs the bench on. Not typed from memory: these are
# `ai_app::ui::fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
# in turn come from her own reports -- keep the three in step.
PHONE_MODE=1080x2424@120Hz
PHONE_SCALE=2.55
DESKTOP_MODE=1920x1200@60Hz
while [ $# -gt 0 ]; do
case "$1" in
--shot) shot=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--bin) kind=bin; shift ;;
--phone) phone=yes; shift ;;
--replay) replay=$2; shift 2 ;;
--dir) workdir=$(cd "$2" && pwd); shift 2 ;;
--) shift; break ;;
*) example=$1; shift ;;
esac
done
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--dir DIR] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
mkdir -p "$run"
export SWAYSOCK="$run/sway.sock"
# Named rather than left to sway's pid-based default, so a second run reuses
# this compositor instead of starting another beside it.
if ! swaymsg -t get_version >/dev/null 2>&1; then
rm -f "$SWAYSOCK"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
setsid sway -c "$scripts/headless.conf" >"$run/sway.log" 2>&1 &
i=0
while [ $i -lt 20 ]; do
swaymsg -t get_version >/dev/null 2>&1 && break
i=$((i + 1)); sleep 0.5
done
swaymsg -t get_version >/dev/null 2>&1 || {
echo "run-headless: compositor did not start; see $run/sway.log" >&2
exit 1
}
fi
rm -f "$run/display"
swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null
i=0
while [ $i -lt 20 ]; do
[ -s "$run/display" ] && break
i=$((i + 1)); sleep 0.5
done
[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; }
WAYLAND_DISPLAY=$(cat "$run/display")
export WAYLAND_DISPLAY
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
# Set every run rather than only when it changes: this compositor is
# reused across runs (see the socket comment above), so a desktop-shaped
# run after a phone-shaped one would otherwise inherit the phone's output
# and silently screenshot the wrong size.
if [ "$phone" = yes ]; then
mode=$PHONE_MODE
export IRIS_SCALE="$PHONE_SCALE"
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
else
mode=$DESKTOP_MODE
fi
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
# The extent `replay-touch` positions against, so a script's coordinates
# are the output's own pixels.
out_w=${mode%x*}
out_h=${mode#*x}; out_h=${out_h%@*}
# Built before the app starts, so a compile error is not reported as a
# window that failed to move.
[ -z "$replay" ] || (cd "$root" && cargo build --bin replay-touch -p rig-input) >&2
cd "$workdir"
if [ "$kind" = bin ]; then
cargo build --bin "$example" "$@" >&2
bin="$workdir/target/debug/$example"
else
cargo build --example "$example" "$@" >&2
bin="$workdir/target/debug/examples/$example"
fi
# shellcheck disable=SC2086 -- deliberately word-split: this is the
# binary's own argv, not a single path.
"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
# Wait for the window to be mapped rather than for a number of seconds. A
# fixed sleep took an all-black screenshot the first time this ran, when sway
# had started in the same invocation and had not composited its output yet --
# which is indistinguishable from an app that draws nothing.
i=0
while [ $i -lt 40 ]; do
kill -0 "$pid" 2>/dev/null || break
swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break
i=$((i + 1)); sleep 0.25
done
i=0
while [ $i -lt "$((seconds * 2))" ]; do
kill -0 "$pid" 2>/dev/null || break
i=$((i + 1)); sleep 0.5
done
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$shot" ]; then
grim "${shot%.png}-before.png"
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
fi
"$root/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
# A fling outlives the finger: the gesture's own last sample is not
# when the list stops. Long enough for Android's spline to settle
# (`FlingCalculator::duration` tops out around a second and a half).
sleep 2
fi
if kill -0 "$pid" 2>/dev/null; then
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
status=0
else
wait "$pid" 2>/dev/null || status=$?
echo "run-headless: $example exited early (status ${status:-0})" >&2
status=${status:-1}
fi
echo "--- $example output ---" >&2
cat "$run/$example.log" >&2
exit "$status"
-102
View File
@@ -1,102 +0,0 @@
use crate::layout_tests::TestRsc;
use crate::prelude::*;
#[test]
fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
let mut rsc = TestRsc { ui: Ui::default() };
let leaf: WeakWidget<Rect> = rect(PaintId::WHITE).label("Add task").add(&mut rsc);
let root = leaf.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let mut access = AccessTree::new();
let update = access
.update(rsc.widgets(), &render, &rsc)
.expect("a first draw with a named widget must produce a tree");
assert_eq!(update.nodes.len(), 2);
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() != accesskit::Role::Window)
.expect("the named widget's own node");
assert_eq!(node.label(), Some("Add task"));
assert_eq!(node.role(), accesskit::Role::Unknown);
let bounds = node.bounds().expect("a drawn widget reports its bounds");
let region = render
.window_region(&leaf, &rsc)
.expect("the widget is active after render.update");
assert_eq!(bounds.x0, region.top_left.x as f64);
assert_eq!(bounds.y0, region.top_left.y as f64);
assert_eq!(bounds.x1, region.bot_right.x as f64);
assert_eq!(bounds.y1, region.bot_right.y as f64);
}
#[test]
fn a_widget_with_no_label_never_reaches_the_tree() {
let mut rsc = TestRsc { ui: Ui::default() };
let root = rsc.ui.widgets.add_strong(rect(PaintId::WHITE));
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root.any(), &mut rsc);
let mut access = AccessTree::new();
assert!(
access.update(rsc.widgets(), &render, &rsc).is_none(),
"no widget was ever `.label()`ed, so there is nothing to report -- \
not even an empty tree change"
);
}
#[test]
fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
let mut rsc = TestRsc { ui: Ui::default() };
let leaf: WeakWidget<Rect> = rect(PaintId::WHITE).label("thing").add(&mut rsc);
let leaf_strong = leaf.upgrade(&mut rsc).any();
let offset = rsc.ui.widgets.add_strong(Offset {
inner: leaf_strong,
amt: UiVec2::ZERO,
});
let offset_id = offset.weak();
let root = offset.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let mut access = AccessTree::new();
access
.update(rsc.widgets(), &render, &rsc)
.expect("the first draw is always a change");
assert_eq!(access.take_rebuilds(), 1);
render.update(&root, &mut rsc);
assert!(access.update(rsc.widgets(), &render, &rsc).is_none());
assert_eq!(access.take_rebuilds(), 0);
let before = render
.window_region(&leaf, &rsc)
.expect("active before the move");
rsc.ui.widgets.get_mut(&offset_id).unwrap().amt = UiVec2::abs(Vec2::new(50.0, 0.0));
render.update(&root, &mut rsc);
let update = access
.update(rsc.widgets(), &render, &rsc)
.expect("a moved named widget is a change");
assert_eq!(access.take_rebuilds(), 1);
let after = render
.window_region(&leaf, &rsc)
.expect("still active after the move");
assert!(
after.top_left.x > before.top_left.x,
"the leaf's reported bounds must move right along with its offset"
);
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() != accesskit::Role::Window)
.unwrap();
let bounds = node.bounds().unwrap();
assert_eq!(bounds.x0, after.top_left.x as f64);
}
-68
View File
@@ -1,68 +0,0 @@
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate};
use accesskit_android::QueuedEvents;
use android_view::{
View,
jni::{JNIEnv, objects::JObject},
};
use iris_core::{AccessTree, UiRenderState, UiRsc, Widgets};
/// The `ActivationHandler` `accesskit_android::Adapter` asks for its
/// initial tree from -- unlike `accesskit_winit`'s handlers (see
/// `desktop/access.rs`), this one is only ever invoked synchronously from
/// inside a JNI callback that already holds everything it needs, so it can
/// just borrow `IrisViewPeer`'s own fields for the length of one call
/// rather than going through a channel.
pub(super) struct AndroidAccessSource<'a> {
pub widgets: &'a Widgets,
pub render: &'a UiRenderState,
pub rsc: &'a dyn UiRsc,
}
impl ActivationHandler for AndroidAccessSource<'_> {
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
Some(AccessTree::build_full(self.widgets, self.render, self.rsc))
}
}
pub(super) struct NullActionHandler;
impl ActionHandler for NullActionHandler {
fn do_action(&mut self, _request: ActionRequest) {}
}
fn is_accessibility_enabled<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) -> bool {
let context = view.context(env);
let name = env.new_string("accessibility").unwrap();
let manager: JObject = env
.call_method(
&context.0,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[(&name).into()],
)
.unwrap()
.l()
.unwrap();
if manager.is_null() {
return false;
}
env.call_method(&manager, "isEnabled", "()Z", &[])
.unwrap()
.z()
.unwrap()
}
/// The one place `QueuedEvents::raise` may be called -- see this module's
/// doc comment. Every call site pushes this as a deferred callback rather
/// than calling it inline, matching android-view's own demo: `raise`
/// itself asks not to be called while the caller holds locks a framework
/// callback might, and a deferred callback runs after the current one has
/// returned them.
pub(super) fn raise_if_enabled<'local>(
env: &mut JNIEnv<'local>,
view: &View<'local>,
events: QueuedEvents,
) {
if is_accessibility_enabled(env, view) {
events.raise(env, &view.0);
}
}
-29
View File
@@ -1,29 +0,0 @@
use crate::attr::{FocusHost, recent_click};
use crate::prelude::*;
use super::view::HasAndroidUiState;
impl<T: HasAndroidUiState> FocusHost for T {
fn recent_click(&mut self) -> bool {
recent_click(&mut self.android_state_mut().last_click)
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.android_state_mut().focus = id;
}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.android_state().focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) {
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
// and this runs deep inside the platform-agnostic sensor dispatch
// with no `CallbackCtx` in reach -- `IrisViewPeer::after_input`
// (`view.rs`) is what actually makes the call, right after the
// sensor pass that got here returns.
if region.is_some() {
self.android_state_mut().pending_show_keyboard = true;
}
}
}
-270
View File
@@ -1,270 +0,0 @@
use crate::prelude::*;
use android_view::{
CAP_MODE_SENTENCES, CallbackCtx, EditorInfo, IME_FLAG_NO_ENTER_ACTION, IME_FLAG_NO_EXTRACT_UI,
IME_FLAG_NO_FULLSCREEN, INPUT_TYPE_CLASS_TEXT, INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT,
INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES, INPUT_TYPE_TEXT_FLAG_MULTI_LINE, InputConnection,
caps_mode,
};
use std::borrow::Cow;
use super::view::{AndroidAppState, IrisViewPeer};
/// Byte offset -> UTF-16 code unit offset, the unit every `InputConnection`
/// method speaks in (Java strings are UTF-16). `TextEdit` is byte-indexed
/// throughout since I1 moved it to parley -- see `edit.rs`'s doc comment on
/// `text()` -- so every crossing of this boundary goes through here rather
/// than through ad hoc counting at each call site.
fn byte_to_utf16(text: &str, byte_idx: usize) -> usize {
text[..byte_idx].encode_utf16().count()
}
fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
let mut utf16_len = 0;
for (byte_idx, ch) in text.char_indices() {
if utf16_len >= utf16_idx {
return byte_idx;
}
utf16_len += ch.len_utf16();
}
text.len()
}
impl<State: AndroidAppState> IrisViewPeer<State> {
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
(!self.rsc.events().controllers.command_target_blocks_input())
.then_some(self.state.android_state().focus)
.flatten()
}
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
let Some(focus) = self.focus() else { return };
let text = focus.get(&self.rsc);
let Some(sel) = text.selection_range() else {
return;
};
let content = text.text();
let sel_start = byte_to_utf16(&content, sel.start) as i32;
let sel_end = byte_to_utf16(&content, sel.end) as i32;
let compose_len = self.state.android_state().compose_len;
let (comp_start, comp_end) = if compose_len > 0 {
let caret = byte_to_utf16(&content, text.caret().unwrap_or(sel.end)) as i32;
(caret - compose_len as i32, caret)
} else {
(-1, -1)
};
let imm = ctx.view.input_method_manager(&mut ctx.env);
imm.update_selection(
&mut ctx.env,
&ctx.view,
sel_start,
sel_end,
comp_start,
comp_end,
);
}
}
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
fn on_create_input_connection<'local>(
&mut self,
ctx: &mut CallbackCtx<'local>,
out_attrs: &EditorInfo<'local>,
) {
// Set once per `InputConnection`, not per field -- Android calls
// this when the view (not a particular widget) attaches to an
// IME. `MULTI_LINE`/`AUTO_CORRECT`/`CAP_SENTENCES` cover both the
// tabs example's composer and a plain single-line field well
// enough that no per-field variant is worth the extra state yet.
out_attrs.set_input_type(
&mut ctx.env,
INPUT_TYPE_CLASS_TEXT
| INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES
| INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT
| INPUT_TYPE_TEXT_FLAG_MULTI_LINE,
);
out_attrs.set_ime_options(
&mut ctx.env,
IME_FLAG_NO_FULLSCREEN | IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_ENTER_ACTION,
);
if let Some(focus) = self.focus() {
let text = focus.get(&self.rsc);
let sel = text.selection_range().unwrap_or(0..0);
let content = text.text();
let start = byte_to_utf16(&content, sel.start) as i32;
let end = byte_to_utf16(&content, sel.end) as i32;
out_attrs.set_initial_sel_start(&mut ctx.env, start);
out_attrs.set_initial_sel_end(&mut ctx.env, end);
let caps = caps_mode(&mut ctx.env, &content, start as usize, CAP_MODE_SENTENCES);
out_attrs.set_initial_caps_mode(&mut ctx.env, caps);
}
}
fn text_before_cursor<'slf>(
&'slf mut self,
_ctx: &mut CallbackCtx,
n: i32,
) -> Option<Cow<'slf, str>> {
if n < 0 {
return None;
}
let focus = self.focus()?;
let text = focus.get(&self.rsc);
let sel = text.selection_range()?;
let content = text.text();
let end_16 = byte_to_utf16(&content, sel.start);
let start_16 = end_16.saturating_sub(n as usize);
let start = utf16_to_byte(&content, start_16);
Some(Cow::Owned(content[start..sel.start].to_owned()))
}
fn text_after_cursor<'slf>(
&'slf mut self,
_ctx: &mut CallbackCtx,
n: i32,
) -> Option<Cow<'slf, str>> {
if n < 0 {
return None;
}
let focus = self.focus()?;
let text = focus.get(&self.rsc);
let sel = text.selection_range()?;
let content = text.text();
let len_16 = byte_to_utf16(&content, content.len());
let start_16 = byte_to_utf16(&content, sel.end);
let end_16 = (start_16 + n as usize).min(len_16);
let end = utf16_to_byte(&content, end_16);
Some(Cow::Owned(content[sel.end..end].to_owned()))
}
fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option<Cow<'slf, str>> {
let focus = self.focus()?;
Some(Cow::Owned(focus.get(&self.rsc).selected_text()?))
}
fn cursor_caps_mode(&mut self, ctx: &mut CallbackCtx, req_modes: u32) -> u32 {
let Some(focus) = self.focus() else {
return 0;
};
let text = focus.get(&self.rsc);
let Some(caret) = text.caret() else {
return 0;
};
let content = text.text();
let off = byte_to_utf16(&content, caret);
caps_mode(&mut ctx.env, &content, off, req_modes)
}
fn delete_surrounding_text(
&mut self,
ctx: &mut CallbackCtx,
before_length: i32,
after_length: i32,
) -> bool {
let Some(focus) = self.focus() else {
return false;
};
let text = focus.get(&self.rsc);
let Some(sel) = text.selection_range() else {
return false;
};
let content = text.text();
let start_16 =
byte_to_utf16(&content, sel.start).saturating_sub(before_length.max(0) as usize);
let len_16 = byte_to_utf16(&content, content.len());
let end_16 = (byte_to_utf16(&content, sel.end) + after_length.max(0) as usize).min(len_16);
let start = utf16_to_byte(&content, start_16);
let end = utf16_to_byte(&content, end_16);
drop(content);
focus(&mut self.rsc).delete_byte_range(start, end);
self.after_input(ctx);
true
}
fn delete_surrounding_text_in_code_points(
&mut self,
ctx: &mut CallbackCtx,
before_length: i32,
after_length: i32,
) -> bool {
self.delete_surrounding_text(ctx, before_length, after_length)
}
fn set_composing_text(
&mut self,
ctx: &mut CallbackCtx,
text: &str,
_new_cursor_position: i32,
) -> bool {
let Some(focus) = self.focus() else {
return false;
};
let compose_len = self.state.android_state().compose_len;
focus(&mut self.rsc).replace(compose_len, text);
self.state.android_state_mut().compose_len = text.chars().count();
self.after_input(ctx);
true
}
fn set_composing_region(&mut self, _ctx: &mut CallbackCtx, _start: i32, _end: i32) -> bool {
// `TextEdit` has no separate composing range to move -- see this
// module's doc comment. Declining (rather than moving the caret,
// which would surprise a caller expecting only a style change)
// is the safer approximation.
false
}
fn finish_composing_text(&mut self, ctx: &mut CallbackCtx) -> bool {
self.state.android_state_mut().compose_len = 0;
self.after_input(ctx);
true
}
fn set_selection(&mut self, ctx: &mut CallbackCtx, start: i32, end: i32) -> bool {
let Some(focus) = self.focus() else {
return false;
};
let text = focus.get(&self.rsc);
let content = text.text();
let byte = utf16_to_byte(&content, end.max(0) as usize);
drop(content);
focus(&mut self.rsc).set_cursor_byte(byte);
let _ = start;
self.after_input(ctx);
true
}
fn perform_editor_action(&mut self, _ctx: &mut CallbackCtx, _editor_action: i32) -> bool {
false
}
fn begin_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool {
true
}
fn end_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool {
true
}
fn send_key_event<'local>(
&mut self,
ctx: &mut CallbackCtx<'local>,
event: &android_view::KeyEvent<'local>,
) -> bool {
let key_code = event.key_code(&mut ctx.env);
let handled = super::input::on_key(
&mut self.rsc,
&mut self.state,
&mut ctx.env,
key_code,
event,
);
if handled {
self.after_input(ctx);
}
handled
}
fn request_cursor_updates(&mut self, _ctx: &mut CallbackCtx, _cursor_update_mode: i32) -> bool {
false
}
}
-39
View File
@@ -1,39 +0,0 @@
use crate::prelude::*;
use android_view::{jni::JNIEnv, ndk::event::Keycode};
use super::view::AndroidAppState;
/// Hardware/synthesized key handling for the field that currently has
/// focus. Most typing on Android goes through the IME's `InputConnection`
/// (`android/ime.rs`) instead -- this only sees what a soft keyboard still
/// sends as a real `KeyEvent` in "not fullscreen" mode (Backspace, Enter,
/// the arrow keys on a physical keyboard) plus whatever `unicode_char`
/// reports for a plain key press. Returns whether anything used the event.
pub(super) fn on_key<'local, State: AndroidAppState>(
rsc: &mut State::Resources,
state: &mut State,
env: &mut JNIEnv<'local>,
key_code: Keycode,
event: &android_view::KeyEvent<'local>,
) -> bool {
let Some(focus) = state.android_state().focus else {
return false;
};
let text = focus(rsc);
match key_code {
Keycode::Del => text.backspace(false),
Keycode::ForwardDel => text.delete(false),
Keycode::DpadLeft => text.motion(Motion::Left, false),
Keycode::DpadRight => text.motion(Motion::Right, false),
Keycode::DpadUp => text.motion(Motion::Up, false),
Keycode::DpadDown => text.motion(Motion::Down, false),
Keycode::MoveHome => text.motion(Motion::LineStart, false),
Keycode::MoveEnd => text.motion(Motion::LineEnd, false),
Keycode::Enter | Keycode::NumpadEnter => text.newline(),
_ => match event.unicode_char(env) {
Some(c) if !c.is_control() => text.insert(&c.to_string()),
_ => return false,
},
}
true
}
-108
View File
@@ -1,108 +0,0 @@
use android_view::{
View,
jni::{
JNIEnv, NativeMethod,
descriptors::Desc,
objects::JClass,
sys::{jint, jlong},
},
};
use std::{
cell::RefCell,
collections::HashMap,
ffi::c_void,
rc::Rc,
sync::{Mutex, OnceLock},
};
use send_wrapper::SendWrapper;
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct Insets {
pub left: i32,
pub top: i32,
pub right: i32,
pub bottom: i32,
pub ime_bottom: i32,
pub ime_visible: bool,
}
#[derive(Default)]
pub struct Shared {
pub insets: Insets,
/// Exposed in diagnostics to distinguish missing callbacks from zero insets.
pub updates: u64,
}
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
fn map() -> &'static Mutex<SharedMap> {
static MAP: OnceLock<Mutex<SharedMap>> = OnceLock::new();
MAP.get_or_init(Default::default)
}
/// Called from `view::new_peer` with the same id android-view's
/// `register_view_peer` returned, so a later `apply_window_insets` call
/// (keyed on that id by Java, which only ever sees the one long) reaches
/// the same `Shared` cell `AndroidUiState` reads from.
pub(super) fn register(id: jlong, shared: Rc<RefCell<Shared>>) {
map().lock().unwrap().insert(id, SendWrapper::new(shared));
}
extern "system" fn unregister_insets<'local>(
_env: JNIEnv<'local>,
_view: View<'local>,
peer: jlong,
) {
map().lock().unwrap().remove(&peer);
}
extern "system" fn apply_window_insets<'local>(
mut env: JNIEnv<'local>,
view: View<'local>,
peer: jlong,
left: jint,
top: jint,
right: jint,
bottom: jint,
ime_bottom: jint,
ime_visible: jint,
) {
if let Some(shared) = map().lock().unwrap().get(&peer) {
let mut shared = shared.borrow_mut();
shared.insets = Insets {
left,
top,
right,
bottom,
ime_bottom,
ime_visible: ime_visible != 0,
};
shared.updates += 1;
}
view.post_frame_callback(&mut env);
}
/// Registers `applyWindowInsetsNative` on the app's own `View` subclass.
/// Called once from `JNI_OnLoad` alongside `android_view::register_view_class`.
pub fn register_native_methods<'local, 'other_local>(
env: &mut JNIEnv<'local>,
class: impl Desc<'local, JClass<'other_local>>,
) {
env.register_native_methods(
class,
&[
NativeMethod {
name: "applyWindowInsetsNative".into(),
sig: "(JIIIIII)V".into(),
fn_ptr: apply_window_insets as *mut c_void,
},
NativeMethod {
name: "unregisterInsetsNative".into(),
sig: "(J)V".into(),
fn_ptr: unregister_insets as *mut c_void,
},
],
)
.unwrap();
}
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more