iris: a device change re-uploads its textures, and a mark is one texture per shape
The bench APK panicked on frame 1 on the emulator:
iris panic at iris/core/src/render/texture.rs:461:22:
texture slot 89 is not a live standalone image: None
widget::mark called Textures::add per widget, so a folded card per tool
call meant a standalone image, a bind group and a draw call each --
hundreds of copies of three pictures. Textures::reset, which the Android
surface-rebuild path calls for a genuinely new renderer, then threw the
slot numbering away with the pixels, leaving every one of those live
handles naming a slot nothing recognised. Its doc had said the only
standalone image in the workspace was tabs-ui's, "confirmed by grep" --
true when written, false the moment mark existed.
Textures::reupload replaces reset: queue every slot for upload again in
slot order, empty slots included, so the new device gets the same slot
numbering and a handle a widget has been holding still names its own
texture. The glyph atlas is no longer cleared on that path either, so an
app switch stops re-rasterising every glyph on screen.
Textures::shared(key, make) is one texture per description, keyed by a
SharedTextureKey the caller packs exactly rather than hashes. mark keys on
direction and colour: three mark textures for the screen, not one a card.
And the devlog can finally show a panic. After a crash, Dev Updater's
query starts the app process for the provider alone, so no activity ran,
so set_crash_dir never replayed the panic hook's file -- the Runtime tab
held one line, the provider announcing itself. DevLogProvider.nativeReady
takes the files directory and does the replay from onCreate; the hook also
saves the dying run's last 80 lines beside the panic, read through a new
non-blocking LogRing::try_tail_text so a panic holding the ring's lock
cannot deadlock the hook.
Verified on this checkout's emulator: opens clean, survives 33 full-screen
scrolls back through the fixture, image_bind_group_creates_prev=1; a real
panic replays into the next launch, and a hand-written last-panic.txt
replays in a process started by a provider query with no activity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c8785b6091
commit
341b7a5922
9 files changed
+550
-57
No files matched your search
@@ -278,6 +278,37 @@ impl LogRing {
|
|||||||
format!("{omitted} earlier lines omitted; full log in Dev Updater's Runtime tab\n{tail}")
|
format!("{omitted} earlier lines omitted; full log in Dev Updater's Runtime tab\n{tail}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The newest `max_lines` lines, formatted, or `None` if the ring is
|
||||||
|
/// locked at this instant.
|
||||||
|
///
|
||||||
|
/// For the one caller that must not block: **the panic hook**. A panic
|
||||||
|
/// raised while this ring's own lock was held -- an allocation failing
|
||||||
|
/// inside [`Self::push`], an assertion in a `log::Log` on the way here
|
||||||
|
/// -- would deadlock the hook against the thread that is panicking,
|
||||||
|
/// and the process would hang instead of aborting, with nothing
|
||||||
|
/// written anywhere. Losing the context lines is the right trade
|
||||||
|
/// against that, and `None` says which happened rather than looking
|
||||||
|
/// like an empty log.
|
||||||
|
pub fn try_tail_text(&self, max_lines: usize) -> Option<String> {
|
||||||
|
let guard = match self.0.try_lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
// A poisoned lock is uncontended, so its contents are still
|
||||||
|
// readable -- the same judgement as `with`.
|
||||||
|
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||||
|
Err(std::sync::TryLockError::WouldBlock) => return None,
|
||||||
|
};
|
||||||
|
let lines = &guard.lines;
|
||||||
|
let from = lines.len().saturating_sub(max_lines);
|
||||||
|
Some(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.skip(from)
|
||||||
|
.map(LogLine::format)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// One line for a diagnostics pane: how much is held, how much was
|
/// One line for a diagnostics pane: how much is held, how much was
|
||||||
/// dropped, and when the last line arrived. "no lines yet" is its own
|
/// dropped, and when the last line arrived. "no lines yet" is its own
|
||||||
/// wording rather than a count of zero with a made-up time, because
|
/// wording rather than a count of zero with a made-up time, because
|
||||||
@@ -587,6 +618,30 @@ mod tests {
|
|||||||
assert!(rest.last().unwrap().ends_with("line 199"));
|
assert!(rest.last().unwrap().ends_with("line 199"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_gives_the_newest_lines_with_no_header() {
|
||||||
|
let ring = LogRing::new(1000, 1 << 20);
|
||||||
|
fill(&ring, 200);
|
||||||
|
let tail = ring.try_tail_text(80).expect("nothing holds the lock");
|
||||||
|
let lines: Vec<&str> = tail.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 80, "the cap, and no header: this is a file");
|
||||||
|
assert!(lines[0].ends_with("line 120"), "{}", lines[0]);
|
||||||
|
assert!(lines[79].ends_with("line 199"), "{}", lines[79]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of the `try_`: the panic hook calls this from a
|
||||||
|
/// thread that may already hold the ring's lock, and a blocking read
|
||||||
|
/// there would hang the process instead of aborting it.
|
||||||
|
#[test]
|
||||||
|
fn try_tail_text_answers_none_rather_than_blocking_on_a_held_lock() {
|
||||||
|
let ring = LogRing::new(10, 1 << 20);
|
||||||
|
fill(&ring, 3);
|
||||||
|
let held = ring.0.lock().expect("fresh ring");
|
||||||
|
assert_eq!(ring.try_tail_text(80), None);
|
||||||
|
drop(held);
|
||||||
|
assert!(ring.try_tail_text(80).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
fn an_empty_ring_says_so_rather_than_reporting_a_time() {
|
||||||
let ring = LogRing::with_defaults();
|
let ring = LogRing::with_defaults();
|
||||||
|
|||||||
@@ -1370,3 +1370,55 @@ worth knowing because it changes what a build of the Android app *is*.
|
|||||||
both the desktop and the phone -- only the directory differs.
|
both the desktop and the phone -- only the directory differs.
|
||||||
`desktop-app --ca` is now the override for a link that carried no CA
|
`desktop-app --ca` is now the override for a link that carried no CA
|
||||||
rather than a required flag.
|
rather than a required flag.
|
||||||
|
|
||||||
|
## 2026-09-08: a new GPU device re-uploads its textures instead of forgetting them, and a mark is one texture per shape
|
||||||
|
|
||||||
|
Two defects with one cause: **`widget::mark` built a texture per widget**,
|
||||||
|
so a transcript screen had one 48x48 standalone image, one bind group and
|
||||||
|
one draw call *per folded card* rather than one per picture -- and the
|
||||||
|
Android surface-rebuild path assumed no long-lived widget held a texture
|
||||||
|
handle at all.
|
||||||
|
|
||||||
|
- **`Textures::reset` is gone; `Textures::reupload` replaces it.** A new
|
||||||
|
GPU device holds none of the old one's textures, but this side still
|
||||||
|
holds their pixels, so the answer is to queue every slot for upload
|
||||||
|
again in slot order (empty slots included, as `PushFree`, so the
|
||||||
|
indices after a hole still land where they were) rather than to throw
|
||||||
|
the slot numbering away. Resetting left every live `TextureHandle`
|
||||||
|
naming a slot nothing recognised: the first frame after the emulator's
|
||||||
|
Vulkan-to-GLES fallback panicked with *"texture slot 89 is not a live
|
||||||
|
standalone image: None"*, before anything had been touched.
|
||||||
|
- **The glyph atlas is no longer cleared on that path either**, which
|
||||||
|
falls out of the same change: its pages are slots here and their pixels
|
||||||
|
are on this side, so re-uploading restores exactly the atlas that was
|
||||||
|
there. An app switch no longer re-rasterises every glyph on screen.
|
||||||
|
- **`Textures::shared(key, make)`** (new): the one texture for a
|
||||||
|
description, built on the first ask and handed out again after, keyed
|
||||||
|
by a `SharedTextureKey { owner, id }` the caller packs *exactly* rather
|
||||||
|
than hashes. The map holds its own reference, so a shared slot is never
|
||||||
|
freed and never recycled under a widget still drawing it. `mark()` is
|
||||||
|
its first caller: three marks now exist for the whole transcript screen
|
||||||
|
(open, closed, collapse) instead of one per card, and the rasterising is
|
||||||
|
paid once.
|
||||||
|
|
||||||
|
## 2026-09-08: an app's own log survives the process that wrote it
|
||||||
|
|
||||||
|
`devlog`'s provider could only ever show the run that was still up. After
|
||||||
|
a crash, Dev Updater's query starts the app process **for the provider
|
||||||
|
alone** -- no activity runs, so `MainActivity.nativeSetFilesDir` never
|
||||||
|
fired and the panic hook's file was never replayed. The Runtime tab
|
||||||
|
therefore showed one line, `iris devlog: serving this app's log at ...`,
|
||||||
|
which is exactly the run nobody needs.
|
||||||
|
|
||||||
|
- **`DevLogProvider.nativeReady` now takes the files directory too**, and
|
||||||
|
`app_log::set_crash_dir` is called from whichever of the provider and
|
||||||
|
the activity runs first (it deletes the file, so the second says
|
||||||
|
nothing).
|
||||||
|
- **The panic hook saves context, not just the panic**: the dying run's
|
||||||
|
last 80 log lines go into the file with it, and are replayed into the
|
||||||
|
new run's ring ahead of the panic line, so the Runtime tab reads
|
||||||
|
chronologically -- what the app was doing, then what killed it, then
|
||||||
|
this run. They are read with a new non-blocking
|
||||||
|
`LogRing::try_tail_text`, because a panic raised while the ring's own
|
||||||
|
lock was held would otherwise deadlock the hook and hang the process
|
||||||
|
instead of aborting it.
|
||||||
@@ -8064,3 +8064,95 @@ states the refresh it ran at.
|
|||||||
**Not yet confirmed on the phone** -- that needs a build in her hands,
|
**Not yet confirmed on the phone** -- that needs a build in her hands,
|
||||||
and the emulator cannot answer it (it is a 60Hz GLES rig, so the defect
|
and the emulator cannot answer it (it is a 60Hz GLES rig, so the defect
|
||||||
is invisible there by construction).
|
is invisible there by construction).
|
||||||
|
|
||||||
|
## Iris's phone report, 2026-09-08 evening: "crashes as soon as I scroll up" and a devlog that showed one line
|
||||||
|
|
||||||
|
Her two sentences: the bench crashes shortly after she scrolls back from
|
||||||
|
where it opens, and Dev Updater's Runtime tab held only `iris devlog:
|
||||||
|
serving this app's log at content://dev.iris.android.demo.bench.devlog`.
|
||||||
|
|
||||||
|
### The devlog could only ever show the run that was still up
|
||||||
|
|
||||||
|
**Fixed.** After a crash, Dev Updater's query starts the app process **for
|
||||||
|
the provider alone**: no activity runs, so
|
||||||
|
`MainActivity.nativeSetFilesDir` never fired, so `app_log::set_crash_dir`
|
||||||
|
never replayed the line the panic hook had written to
|
||||||
|
`files/last-panic.txt`. The one line she saw is the provider announcing
|
||||||
|
itself in a process that had just been started to answer her.
|
||||||
|
|
||||||
|
`DevLogProvider.nativeReady` takes the files directory now and calls
|
||||||
|
`set_crash_dir` from `onCreate`; whichever of the provider and the
|
||||||
|
activity runs first does the replay, and the file is deleted before it is
|
||||||
|
replayed, so a replay that itself panicked cannot make a loop. The hook
|
||||||
|
also saves the dying run's last 80 log lines beside the panic (a new
|
||||||
|
non-blocking `LogRing::try_tail_text`, so a panic raised while the ring's
|
||||||
|
lock was held cannot deadlock the hook), replayed into the new ring ahead
|
||||||
|
of the panic line under the target `previous_run`.
|
||||||
|
|
||||||
|
Verified on this checkout's emulator both ways: a real panic replayed into
|
||||||
|
the next launch, and a hand-written `files/last-panic.txt` replayed by a
|
||||||
|
process started by `content query` alone with **no activity** (the query
|
||||||
|
is refused for the `shell` uid, which does not hold
|
||||||
|
`dev.updater.permission.READ_DEVLOG` -- the provider's `onCreate` still
|
||||||
|
runs, which is the half under test).
|
||||||
|
|
||||||
|
### The crash: one texture per mark, and a device change that forgot them
|
||||||
|
|
||||||
|
The bench APK **panicked on frame 1** on this checkout's emulator, which
|
||||||
|
is where this was found rather than by reasoning from her report:
|
||||||
|
|
||||||
|
iris panic at iris/core/src/render/texture.rs:461:22:
|
||||||
|
texture slot 89 is not a live standalone image: None
|
||||||
|
|
||||||
|
`widget::mark` (added the same day) called `Textures::add` **per widget**,
|
||||||
|
so a folded card per tool call meant a standalone image, a bind group and
|
||||||
|
a draw call each -- slot 89 was one of hundreds of copies of three
|
||||||
|
pictures. `Textures::reset`, which the Android surface-rebuild path calls
|
||||||
|
when it builds a genuinely new renderer, then threw the slot *numbering*
|
||||||
|
away along with the pixels, leaving every one of those live handles naming
|
||||||
|
a slot nothing recognised. Its own doc had said the only standalone image
|
||||||
|
in the workspace was `tabs-ui`'s, "confirmed by grep" -- true when it was
|
||||||
|
written, false the moment `mark` existed. **A rule stated on one member of
|
||||||
|
a set** (CODE_RULES): the grep was the invariant, and nothing re-ran it.
|
||||||
|
|
||||||
|
Two fixes, both in `iris-core`:
|
||||||
|
|
||||||
|
- **`Textures::reupload` replaces `reset`**: queue every slot for upload
|
||||||
|
again in slot order, empty slots included (they cross as `PushFree`, so
|
||||||
|
the indices after a hole still land where they were). This side holds
|
||||||
|
the images, so the new device gets the same slot numbering and every
|
||||||
|
handle a widget is holding still names its own texture. The glyph atlas
|
||||||
|
is no longer cleared on that path either -- its pages are slots here, so
|
||||||
|
they come back with everything else, and an app switch stops costing a
|
||||||
|
re-rasterisation of every glyph on screen.
|
||||||
|
- **`Textures::shared(key, make)`**: one texture per description. `mark`
|
||||||
|
keys on direction and colour, packed exactly into a
|
||||||
|
`SharedTextureKey { owner, id }` rather than hashed, so the screen has
|
||||||
|
three mark textures rather than one per card and the rasterising is paid
|
||||||
|
once. The map holds its own reference, so a shared slot is never freed
|
||||||
|
and never recycled under a widget still drawing it.
|
||||||
|
|
||||||
|
After the fix the emulator opens clean and survives 33 full-screen scroll
|
||||||
|
gestures back through the fixture, with
|
||||||
|
`image_bind_group_creates_prev=1`.
|
||||||
|
|
||||||
|
**Whether this is the crash on *her* phone is not yet confirmed** -- her
|
||||||
|
build is release, its startup does not take the GLES fallback that made
|
||||||
|
the emulator rebuild its renderer, and the panic she saw was never
|
||||||
|
recorded anywhere. It is the crash this code had; the devlog fix above is
|
||||||
|
what will say whether it was hers. Ask for the Runtime tab after the next
|
||||||
|
bench APK.
|
||||||
|
|
||||||
|
### Open: why `mark` exists at all (Iris asked, 2026-09-08)
|
||||||
|
|
||||||
|
Her question, mid-fix: "why does mark exist? The font should be working if
|
||||||
|
it's working for compose and nerd fonts are bundled." The Compose app
|
||||||
|
draws its chevron from **its own committed Nerd Fonts subset**
|
||||||
|
(`app/build-icon-font.sh`, `NerdIcons.kt`); iris bundles no font at all
|
||||||
|
since 2026-09-07, and `tool.rs` was using bare geometric codepoints
|
||||||
|
(U+25B8/25BE/25B4) out of whatever system face resolved -- which her phone
|
||||||
|
had none for. So the two apps were never doing the same thing, and the
|
||||||
|
2026-09-07 note that "iris had no equivalent icon font to keep" is what
|
||||||
|
left the gap. The alternative to `mark` is to bundle the same ~100-glyph
|
||||||
|
subset in iris and take icons from it, which would serve every future icon
|
||||||
|
rather than one triangle. Not done: it is hers to choose.
|
||||||
@@ -74,8 +74,15 @@ public final class DevLogProvider extends ContentProvider {
|
|||||||
* the diagnostics pane can name somewhere a reader can actually query
|
* the diagnostics pane can name somewhere a reader can actually query
|
||||||
* -- and so "declared but never created" is a state it can say. Only
|
* -- and so "declared but never created" is a state it can say. Only
|
||||||
* the provider knows it was instantiated; Android creates one lazily.
|
* the provider knows it was instantiated; Android creates one lazily.
|
||||||
|
*
|
||||||
|
* <p>The files directory goes with it because <em>this is usually the
|
||||||
|
* only thing running</em>: after the app has died, Dev Updater's query
|
||||||
|
* starts the process for the provider alone, with no activity, so
|
||||||
|
* {@code MainActivity.nativeSetFilesDir} is never called and the line
|
||||||
|
* the panic hook left on disk is never replayed into the ring. That is
|
||||||
|
* exactly the run whose log somebody wants.
|
||||||
*/
|
*/
|
||||||
private static native void nativeReady(String authority);
|
private static native void nativeReady(String authority, String filesDir);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCreate() {
|
public boolean onCreate() {
|
||||||
@@ -87,7 +94,7 @@ public final class DevLogProvider extends ContentProvider {
|
|||||||
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
matcher = new UriMatcher(UriMatcher.NO_MATCH);
|
||||||
matcher.addURI(authority, "lines", LINES);
|
matcher.addURI(authority, "lines", LINES);
|
||||||
matcher.addURI(authority, "status", STATUS);
|
matcher.addURI(authority, "status", STATUS);
|
||||||
nativeReady(authority);
|
nativeReady(authority, getContext().getFilesDir().getAbsolutePath());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,11 +71,29 @@ pub fn diagnostics_line() -> String {
|
|||||||
format!("{}\n{where_to_read}", ring().summary())
|
format!("{}\n{where_to_read}", ring().summary())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the panic hook leaves its one line, under the app's private
|
/// Where the panic hook leaves its report, under the app's private
|
||||||
/// directory. Read back and dropped by [`set_crash_dir`] on the next
|
/// directory. Read back and dropped by [`set_crash_dir`] on the next
|
||||||
/// start.
|
/// start.
|
||||||
const CRASH_FILE: &str = "last-panic.txt";
|
const CRASH_FILE: &str = "last-panic.txt";
|
||||||
|
|
||||||
|
/// How many of the dying run's own log lines the panic hook saves with
|
||||||
|
/// the panic, and [`set_crash_dir`] replays.
|
||||||
|
///
|
||||||
|
/// The panic's message and location say *what* broke; these say what the
|
||||||
|
/// app was doing on the way there, which is the half that is otherwise
|
||||||
|
/// unrecoverable -- the ring is memory only, so an abort takes every line
|
||||||
|
/// before the panic with it. Bounded rather than the whole ring because
|
||||||
|
/// this is written by a hook on a process that is about to die, and
|
||||||
|
/// because the replay pushes each line into the new run's ring, where an
|
||||||
|
/// unbounded paste would evict the run that is actually being watched.
|
||||||
|
const CRASH_CONTEXT_LINES: usize = 80;
|
||||||
|
|
||||||
|
/// The target the replayed context lines carry, so a reader can tell a
|
||||||
|
/// line from the run that died from one this run wrote. They keep their
|
||||||
|
/// original timestamp and level inside the text, which is why the level
|
||||||
|
/// they are re-pushed at is not meaningful and the target has to be.
|
||||||
|
const PREVIOUS_RUN_TARGET: &str = "previous_run";
|
||||||
|
|
||||||
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
/// Installs a `log`-level panic hook, so a panic's message and location
|
/// Installs a `log`-level panic hook, so a panic's message and location
|
||||||
@@ -105,26 +123,64 @@ fn install_panic_hook() {
|
|||||||
let line = format!("iris panic at {where_at}: {message}");
|
let line = format!("iris panic at {where_at}: {message}");
|
||||||
log::error!("{line}");
|
log::error!("{line}");
|
||||||
if let Some(path) = CRASH_PATH.get() {
|
if let Some(path) = CRASH_PATH.get() {
|
||||||
|
// The panic line first, then what the app was doing before
|
||||||
|
// it: one file, split again on that first newline by
|
||||||
|
// `set_crash_dir`.
|
||||||
|
let context = ring()
|
||||||
|
.try_tail_text(CRASH_CONTEXT_LINES)
|
||||||
|
// Said rather than left empty, so "the ring was locked as
|
||||||
|
// we died" cannot be read as "nothing had been logged".
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
"(the log ring was locked as this run died; no context)".to_string()
|
||||||
|
});
|
||||||
// Best effort by design: a panic is already the failure, and
|
// Best effort by design: a panic is already the failure, and
|
||||||
// failing to record it must not become a second one.
|
// failing to record it must not become a second one.
|
||||||
let _ = std::fs::write(path, &line);
|
let _ = std::fs::write(path, format!("{line}\n{context}"));
|
||||||
}
|
}
|
||||||
previous(info);
|
previous(info);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tells the panic hook where to leave its line, and replays the line a
|
/// Tells the panic hook where to leave its report, and replays the report
|
||||||
/// previous run left there into the ring before deleting it.
|
/// a previous run left there into the ring before deleting it.
|
||||||
///
|
///
|
||||||
/// Called from `nativeSetFilesDir`, which is the first moment the app's
|
/// Called from **both** `MainActivity.nativeSetFilesDir` and
|
||||||
/// private directory is known. The replay is at `error` level and says
|
/// `DevLogProvider.nativeReady` -- whichever of the two runs first in
|
||||||
/// it is from the previous run, so a crash loop shows the reason it is
|
/// this process, since after a crash Dev Updater's query starts the
|
||||||
/// looping in the Runtime tab of the run that is still up.
|
/// process for the provider alone and no activity ever runs. Safe to call
|
||||||
|
/// twice: the file is gone after the first, so the second finds nothing
|
||||||
|
/// and says nothing. The panic itself is replayed at `error` level and
|
||||||
|
/// says it is from the previous run, so a crash loop shows the reason it
|
||||||
|
/// is looping in the Runtime tab of the run that is still up.
|
||||||
pub fn set_crash_dir(dir: &std::path::Path) {
|
pub fn set_crash_dir(dir: &std::path::Path) {
|
||||||
let path = dir.join(CRASH_FILE);
|
let path = dir.join(CRASH_FILE);
|
||||||
if let Ok(previous) = std::fs::read_to_string(&path) {
|
if let Ok(previous) = std::fs::read_to_string(&path) {
|
||||||
log::error!("iris app log: the previous run died -- {}", previous.trim());
|
// Delete before replaying rather than after: a replay that itself
|
||||||
|
// panicked would otherwise leave the file to be replayed again on
|
||||||
|
// every start, and a crash loop nothing can get out of is worse
|
||||||
|
// than one report lost.
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
|
replay_crash(&previous);
|
||||||
}
|
}
|
||||||
let _ = CRASH_PATH.set(path);
|
let _ = CRASH_PATH.set(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Puts a previous run's report back in the ring: its context lines in
|
||||||
|
/// the order they happened, then the panic itself.
|
||||||
|
///
|
||||||
|
/// Chronological, so the Runtime tab reads as one story -- the lines that
|
||||||
|
/// led to the crash, then the crash, then this run. The context goes in
|
||||||
|
/// through `LogRing::push` rather than through `log::info!` so it is not
|
||||||
|
/// stamped with this run's clock: each line already carries the time and
|
||||||
|
/// level it was written at, and [`PREVIOUS_RUN_TARGET`] is what says
|
||||||
|
/// whose run it was.
|
||||||
|
fn replay_crash(report: &str) {
|
||||||
|
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
|
||||||
|
for line in context.lines().filter(|line| !line.is_empty()) {
|
||||||
|
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
|
||||||
|
}
|
||||||
|
log::error!(
|
||||||
|
"iris app log: the previous run died -- {}",
|
||||||
|
panic_line.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -50,7 +50,17 @@ pub fn authority() -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `DevLogProvider.nativeReady` -- the provider announcing the authority
|
/// `DevLogProvider.nativeReady` -- the provider announcing the authority
|
||||||
/// it registered under, from its own `onCreate`.
|
/// it registered under and the app's private directory, from its own
|
||||||
|
/// `onCreate`.
|
||||||
|
///
|
||||||
|
/// The directory is taken here as well as in
|
||||||
|
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
|
||||||
|
/// only thing running**: once the app has died, Dev Updater's query
|
||||||
|
/// starts the process for the provider alone, so no activity ever runs
|
||||||
|
/// and the panic hook's file would never be replayed into the ring. That
|
||||||
|
/// is precisely the run whose log is being asked for. Whichever of the
|
||||||
|
/// two arrives first does the replay; `set_crash_dir` deletes the file,
|
||||||
|
/// so the second finds nothing and says nothing.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// Called by the JVM with the arguments its `native` declaration names.
|
/// Called by the JVM with the arguments its `native` declaration names.
|
||||||
@@ -59,18 +69,31 @@ pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
|||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
_class: JClass,
|
_class: JClass,
|
||||||
authority: JString,
|
authority: JString,
|
||||||
|
files_dir: JString,
|
||||||
) {
|
) {
|
||||||
if authority.is_null() {
|
// Before the authority line, so the previous run's death is above the
|
||||||
return;
|
// line announcing this one rather than buried under it.
|
||||||
|
#[cfg(feature = "transcript-screen")]
|
||||||
|
if let Some(dir) = string_arg(&mut env, &files_dir) {
|
||||||
|
crate::app_log::set_crash_dir(std::path::Path::new(&dir));
|
||||||
}
|
}
|
||||||
let Ok(authority) = env.get_string(&authority) else {
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
|
let _ = &files_dir;
|
||||||
|
let Some(authority) = string_arg(&mut env, &authority) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let authority: String = authority.into();
|
|
||||||
log::info!("iris devlog: serving this app's log at content://{authority}");
|
log::info!("iris devlog: serving this app's log at content://{authority}");
|
||||||
let _ = AUTHORITY.set(authority);
|
let _ = AUTHORITY.set(authority);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One `String` argument, or `None` for a null or unreadable one.
|
||||||
|
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
||||||
|
if value.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
env.get_string(value).ok().map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
|
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
|
||||||
/// three strings.
|
/// three strings.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::util::{RefCounter, Vec2};
|
use crate::util::{RefCounter, Vec2};
|
||||||
use image::{DynamicImage, GenericImageView};
|
use image::{DynamicImage, GenericImageView};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
ops::Index,
|
ops::Index,
|
||||||
sync::mpsc::{Receiver, Sender, channel},
|
sync::mpsc::{Receiver, Sender, channel},
|
||||||
};
|
};
|
||||||
@@ -21,6 +22,16 @@ pub enum TextureKind {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a [`Textures::shared`] texture is a picture of -- exactly, not by
|
||||||
|
/// hash: `owner` names the widget kind whose description it is, and `id`
|
||||||
|
/// packs that description's own fields, so two owners cannot collide and
|
||||||
|
/// a debugger shows which picture a slot holds.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SharedTextureKey {
|
||||||
|
pub owner: &'static str,
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TextureHandle {
|
pub struct TextureHandle {
|
||||||
slot: u32,
|
slot: u32,
|
||||||
@@ -35,6 +46,14 @@ pub struct TextureHandle {
|
|||||||
pub struct Textures {
|
pub struct Textures {
|
||||||
free: Vec<u32>,
|
free: Vec<u32>,
|
||||||
images: Vec<Option<DynamicImage>>,
|
images: Vec<Option<DynamicImage>>,
|
||||||
|
/// What each slot is, kept beside the image so a slot can be pushed
|
||||||
|
/// again without the handle that knows -- see [`Textures::reupload`].
|
||||||
|
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. Pages are never freed (no
|
/// Next layer to hand out to an atlas page. Pages are never freed (no
|
||||||
/// atlas eviction), so this only grows and `free` never holds one.
|
/// atlas eviction), so this only grows and `free` never holds one.
|
||||||
next_page_layer: u32,
|
next_page_layer: u32,
|
||||||
@@ -77,6 +96,8 @@ impl Textures {
|
|||||||
Self {
|
Self {
|
||||||
free: Vec::new(),
|
free: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
|
kinds: Vec::new(),
|
||||||
|
shared: HashMap::new(),
|
||||||
next_page_layer: 0,
|
next_page_layer: 0,
|
||||||
updates: Vec::new(),
|
updates: Vec::new(),
|
||||||
send,
|
send,
|
||||||
@@ -119,16 +140,46 @@ impl Textures {
|
|||||||
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
|
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
|
||||||
if let Some(i) = self.free.pop() {
|
if let Some(i) = self.free.pop() {
|
||||||
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(kind, i));
|
self.updates.push(Update::Set(kind, i));
|
||||||
i
|
i
|
||||||
} else {
|
} else {
|
||||||
let i = self.images.len() as u32;
|
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(kind, i));
|
self.updates.push(Update::Push(kind, i));
|
||||||
i
|
i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one texture for `key`, building it on the first ask and handing
|
||||||
|
/// out a further reference to it every time after.
|
||||||
|
///
|
||||||
|
/// **Why this exists**: a texture rasterised from a *description* --
|
||||||
|
/// `widget::mark`'s triangle, from a direction and a colour -- has as
|
||||||
|
/// many copies as there are widgets asking for it, and each copy is
|
||||||
|
/// its own GPU texture, its own bind group and its own draw call. A
|
||||||
|
/// transcript screen with a folded card per tool call built one per
|
||||||
|
/// card: hundreds of 48x48 textures of three distinct pictures,
|
||||||
|
/// created and freed again as rows recycled. `make` is not called when
|
||||||
|
/// the key is already known, so the rasterising is paid once too.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
/// The stored image for a handle, to be written into before `patch`.
|
/// The stored image for a handle, to be written into before `patch`.
|
||||||
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
|
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
|
||||||
self.images[handle.slot as usize]
|
self.images[handle.slot as usize]
|
||||||
@@ -141,25 +192,35 @@ impl Textures {
|
|||||||
self.updates.push(Update::Patch(handle.slot, rect));
|
self.updates.push(Update::Patch(handle.slot, rect));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget every image, page and pending update -- what a genuinely new
|
/// Queue every live slot for upload again, in slot order -- what a
|
||||||
/// GPU device needs alongside [`crate::render::atlas::GlyphAtlas::
|
/// genuinely new GPU device needs, in place of forgetting everything.
|
||||||
/// clear`], which this module's own doc references: every slot number
|
///
|
||||||
/// and every queued [`Update`] here describes the *old* device's
|
/// A new device starts with no textures, and the renderer-side mirror
|
||||||
/// textures (an `Update::Push`/`Update::Patch` already drained into a
|
/// of these slots (`render::texture::GpuTextures`) starts empty with
|
||||||
/// renderer that no longer exists is gone for good, and a fresh
|
/// it. What it must not do is start empty while the handles widgets
|
||||||
/// `UiRenderNode`'s own texture manager starts with none of them
|
/// are still holding name slots by *index*: `Textures::reset` used to
|
||||||
/// applied), so nothing is lost by starting this bookkeeping over too.
|
/// throw this bookkeeping away, which left every live `TextureHandle`
|
||||||
/// Any `TextureHandle` a caller still holds across the reset (none in
|
/// -- one per `widget::mark`, hundreds on a transcript screen --
|
||||||
/// the transcript screen this reset is wired up for today -- confirmed
|
/// pointing at a slot nothing recognised, and the first frame after an
|
||||||
/// by grep, the only standalone (non-atlas) image anywhere in this
|
/// Android surface rebuild panicked in `image_bind_group` ("texture
|
||||||
/// workspace is `iris/widget/image.rs`'s `Image`, used by the separate
|
/// slot 89 is not a live standalone image: None"). Re-uploading
|
||||||
/// `tabs-ui` example) is left pointing at a slot this instance no
|
/// instead keeps every index meaning what it meant, because this side
|
||||||
/// longer recognises and needs reinserting via `add`/`add_page` again
|
/// still holds the images: the slot list is rebuilt identically,
|
||||||
/// -- the same pre-existing gap a renderer restart already left for
|
/// including the empty slots, which go across as `PushFree` so the
|
||||||
/// such a handle before this method existed, just named rather than
|
/// ones after them still land where they were.
|
||||||
/// silent now.
|
///
|
||||||
pub fn reset(&mut self) {
|
/// The glyph atlas comes back with it and is deliberately *not*
|
||||||
*self = Self::new();
|
/// cleared any more: its pages are slots here, this side holds their
|
||||||
|
/// pixels, and re-uploading them restores exactly the atlas that was
|
||||||
|
/// there -- so an app switch no longer costs a re-rasterisation of
|
||||||
|
/// every glyph on screen either.
|
||||||
|
///
|
||||||
|
/// Pending updates are dropped rather than kept: each is either a push
|
||||||
|
/// or a patch of a slot this replays in full.
|
||||||
|
pub fn reupload(&mut self) {
|
||||||
|
self.updates.clear();
|
||||||
|
self.updates
|
||||||
|
.extend((0..self.images.len() as u32).map(|i| Update::Push(self.kinds[i as usize], i)));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn free(&mut self) {
|
pub fn free(&mut self) {
|
||||||
@@ -245,3 +306,90 @@ 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `widget::mark` needs: one texture per description, however
|
||||||
|
/// many widgets ask for it, and a different description is a
|
||||||
|
/// different texture.
|
||||||
|
#[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map's own reference is what keeps a shared slot alive: every
|
||||||
|
/// widget holding one can go away and the slot must not be recycled,
|
||||||
|
/// because the next widget to ask gets that same index back.
|
||||||
|
#[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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new GPU device gets the same slot numbering back, so a handle a
|
||||||
|
/// widget has been holding all along still names its own texture --
|
||||||
|
/// the crash `reupload` replaced `reset` to fix.
|
||||||
|
#[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();
|
||||||
|
// Drain the updates so far, the way a frame does.
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-17
@@ -853,29 +853,33 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
|||||||
let content_scale = self.state.android_state().content_scale;
|
let content_scale = self.state.android_state().content_scale;
|
||||||
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
|
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
|
||||||
Ok(renderer) => {
|
Ok(renderer) => {
|
||||||
// A genuinely new renderer means a genuinely new GPU device
|
// A genuinely new renderer means a genuinely new GPU
|
||||||
// and a fresh, empty glyph atlas -- the CPU-side glyph
|
// device, holding none of the textures the old one did --
|
||||||
// cache (`TextData::atlas`) and the texture bookkeeping it
|
// while the CPU side of them (`UiData::textures`, and the
|
||||||
// is built on (`UiData::textures`) both outlive `renderer`
|
// glyph atlas built on it) lives on `self.rsc` and
|
||||||
// itself (they live on `self.rsc`, not on `AndroidRenderer`),
|
// survives. So every slot has to be uploaded again, and
|
||||||
// so without this they would keep pointing at the *old*
|
// `Textures::reupload` queues exactly that, in slot order.
|
||||||
// device's now-gone textures -- the app-switch counterpart
|
//
|
||||||
// to the keyboard-resize glyph wipe this same function's
|
// It replaces clearing them, which threw away the *slot
|
||||||
// `already_live` branch above already fixed by reusing the
|
// numbering* as well as the pixels: every `TextureHandle`
|
||||||
// renderer instead of rebuilding it. One mechanism either
|
// a live widget still held -- one per `widget::mark`, so
|
||||||
// way: this call only runs on the branch that actually
|
// one per folded card on the transcript screen -- then
|
||||||
// builds a new renderer, exactly where invalidation is
|
// named a slot nothing recognised, and the next frame
|
||||||
// needed, never on the reuse branch, where it would throw
|
// panicked in `image_bind_group` ("texture slot 89 is not
|
||||||
// away perfectly valid GPU state for nothing.
|
// a live standalone image: None"). Re-uploading also keeps
|
||||||
|
// the glyph atlas, so an app switch no longer re-rasterises
|
||||||
|
// every glyph on screen. This only runs on the branch that
|
||||||
|
// actually builds a new renderer, never on the reuse
|
||||||
|
// branch above, where the textures are still on the device
|
||||||
|
// that holds them.
|
||||||
log::info!(
|
log::info!(
|
||||||
"iris surface: new renderer built ({:?}), clearing glyph atlas: \
|
"iris surface: new renderer built ({:?}), re-uploading textures: \
|
||||||
glyphs={} pages={}",
|
glyphs={} pages={}",
|
||||||
renderer.adapter_backend,
|
renderer.adapter_backend,
|
||||||
self.rsc.ui.text.atlas.glyph_count(),
|
self.rsc.ui.text.atlas.glyph_count(),
|
||||||
self.rsc.ui.text.atlas.page_count(),
|
self.rsc.ui.text.atlas.page_count(),
|
||||||
);
|
);
|
||||||
self.rsc.ui.text.atlas.clear();
|
self.rsc.ui.textures.reupload();
|
||||||
self.rsc.ui.textures.reset();
|
|
||||||
self.state.android_state_mut().renderer = Some(renderer);
|
self.state.android_state_mut().renderer = Some(renderer);
|
||||||
self.render(ctx);
|
self.render(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-5
@@ -12,18 +12,24 @@
|
|||||||
//! Rasterised into iris's ordinary texture path rather than needing a new
|
//! Rasterised into iris's ordinary texture path rather than needing a new
|
||||||
//! primitive: iris has rects, text and textures, and a triangle is not
|
//! primitive: iris has rects, text and textures, and a triangle is not
|
||||||
//! expressible as any number of rects without a staircase edge. One
|
//! expressible as any number of rects without a staircase edge. One
|
||||||
//! oversampled bitmap per mark is drawn scaled into the box the caller
|
//! oversampled bitmap per *shape* is drawn scaled into the box the caller
|
||||||
//! asks for, so the same texture is correct at any density -- which is
|
//! asks for, so the same texture is correct at any density -- which is
|
||||||
//! also why it is built at construction, where the density is not known
|
//! also why it is built at construction, where the density is not known
|
||||||
//! yet, and scaled at draw, where it is.
|
//! yet, and scaled at draw, where it is.
|
||||||
|
//!
|
||||||
|
//! Per shape, not per widget: the bitmap depends only on the direction and
|
||||||
|
//! the colour, so it goes through [`Textures::shared`] and a screen full of
|
||||||
|
//! folded cards draws the three marks it actually has rather than one
|
||||||
|
//! texture, bind group and draw call per card.
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use image::{Rgba, RgbaImage};
|
use image::{Rgba, RgbaImage};
|
||||||
|
use iris_core::SharedTextureKey;
|
||||||
|
|
||||||
/// The bitmap's own size. Generous enough that a 12dp mark at density 3
|
/// The bitmap's own size. Generous enough that a 12dp mark at density 3
|
||||||
/// (36px) is still sampling *down*, which is what keeps the diagonal
|
/// (36px) is still sampling *down*, which is what keeps the diagonal
|
||||||
/// clean; small enough that a handful of them cost nothing (48x48 RGBA is
|
/// clean; small enough that the handful the program has cost nothing
|
||||||
/// 9 KB, and a card draws one).
|
/// (48x48 RGBA is 9 KB).
|
||||||
const TEXTURE_PX: u32 = 48;
|
const TEXTURE_PX: u32 = 48;
|
||||||
/// Subsamples per pixel per axis when measuring how much of a pixel the
|
/// Subsamples per pixel per axis when measuring how much of a pixel the
|
||||||
/// triangle covers. 4x4 is the point where the edge stops looking stepped
|
/// triangle covers. 4x4 is the point where the edge stops looking stepped
|
||||||
@@ -53,14 +59,39 @@ impl Widget for Mark {
|
|||||||
|
|
||||||
/// A filled triangle `size_dp` across, pointing along `dir`, in `color` --
|
/// A filled triangle `size_dp` across, pointing along `dir`, in `color` --
|
||||||
/// what a row uses to say "this opens" and "this is open".
|
/// what a row uses to say "this opens" and "this is open".
|
||||||
|
///
|
||||||
|
/// `size_dp` is not part of what is rasterised (the bitmap is scaled at
|
||||||
|
/// draw), so two marks differing only in size share one texture.
|
||||||
pub fn mark<Rsc: UiRsc>(dir: Dir, size_dp: f32, color: UiColor) -> impl WidgetFn<Rsc, Mark> {
|
pub fn mark<Rsc: UiRsc>(dir: Dir, size_dp: f32, color: UiColor) -> impl WidgetFn<Rsc, Mark> {
|
||||||
let image = rasterise(dir, color);
|
|
||||||
move |state| Mark {
|
move |state| Mark {
|
||||||
handle: state.ui_mut().textures.add(image),
|
handle: state
|
||||||
|
.ui_mut()
|
||||||
|
.textures
|
||||||
|
.shared(key(dir, color), || rasterise(dir, color).into()),
|
||||||
size_dp,
|
size_dp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The mark's whole description, packed exactly: the axis and sign of the
|
||||||
|
/// direction and the four colour channels, one byte each, so distinct
|
||||||
|
/// marks are distinct keys and equal ones are equal.
|
||||||
|
fn key(dir: Dir, color: UiColor) -> SharedTextureKey {
|
||||||
|
let axis = match dir.axis {
|
||||||
|
Axis::X => 0u64,
|
||||||
|
Axis::Y => 1,
|
||||||
|
};
|
||||||
|
let sign = match dir.sign {
|
||||||
|
Sign::Neg => 0u64,
|
||||||
|
Sign::Pos => 1,
|
||||||
|
};
|
||||||
|
SharedTextureKey {
|
||||||
|
owner: "mark",
|
||||||
|
id: u64::from_le_bytes([
|
||||||
|
axis as u8, sign as u8, color.r, color.g, color.b, color.a, 0, 0,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The triangle, as coverage: for each pixel, how much of it the shape
|
/// The triangle, as coverage: for each pixel, how much of it the shape
|
||||||
/// covers, measured by subsampling rather than by an analytic edge
|
/// covers, measured by subsampling rather than by an analytic edge
|
||||||
/// function -- one bitmap is built per mark in the whole program, so the
|
/// function -- one bitmap is built per mark in the whole program, so the
|
||||||
@@ -175,6 +206,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two marks of the same shape share a texture and two of different
|
||||||
|
/// shapes do not -- the whole of what [`Textures::shared`] is being
|
||||||
|
/// keyed on, checked here rather than through a widget so it does not
|
||||||
|
/// need a resource tree.
|
||||||
|
#[test]
|
||||||
|
fn a_marks_key_is_its_direction_and_its_colour() {
|
||||||
|
assert_eq!(key(Dir::UP, UiColor::WHITE), key(Dir::UP, UiColor::WHITE));
|
||||||
|
for (name, other) in [
|
||||||
|
("down", Dir::DOWN),
|
||||||
|
("left", Dir::LEFT),
|
||||||
|
("right", Dir::RIGHT),
|
||||||
|
] {
|
||||||
|
assert_ne!(
|
||||||
|
key(Dir::UP, UiColor::WHITE),
|
||||||
|
key(other, UiColor::WHITE),
|
||||||
|
"{name} shares a key with up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
key(Dir::UP, UiColor::WHITE),
|
||||||
|
key(Dir::UP, UiColor::new(255, 255, 255, 128)),
|
||||||
|
"two colours differing only in alpha share a key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A corner of the bitmap is never covered, whichever way the mark
|
/// A corner of the bitmap is never covered, whichever way the mark
|
||||||
/// points -- what says the shape is a triangle rather than a filled
|
/// points -- what says the shape is a triangle rather than a filled
|
||||||
/// box, and that the inset in `corners` is keeping its antialiasing
|
/// box, and that the inset in `corners` is keeping its antialiasing
|
||||||
|
|||||||
Reference in new issue
Block a user