iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent 7b54aaf3c4
commit a9312e9431
113 files changed
+23221 -2992

No files matched your search

+114 -89
View File
@@ -6,11 +6,8 @@ pub use edit::*;
use iris_core::util::MutDetect;
use crate::prelude::*;
use cosmic_text::{Attrs, BufferLine, Cursor, Metrics, Shaping};
use std::ops::{Deref, DerefMut};
pub const SHAPING: Shaping = Shaping::Advanced;
pub struct Text {
pub content: MutDetect<String>,
view: TextView,
@@ -25,6 +22,18 @@ pub struct TextView {
pub hint: Option<StrongWidget>,
}
impl TextView {
fn is_blank(&self) -> bool {
self.buf.is_empty()
}
/// The width the text was last laid out against, so an editor asking for
/// the layout gets the same wrapping the last draw used.
pub fn wrap_width(&self) -> Option<f32> {
self.width
}
}
impl TextView {
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self {
Self {
@@ -45,33 +54,44 @@ impl TextView {
.align(self.align)
}
fn tex_region(&self, tex: &RenderedText) -> UiRegion {
let region = tex.size.align(self.align);
let dims = tex.handle.size();
let mut region = region.offset(tex.top_left_offset);
region.x.end = region.x.start + UiScalar::abs(dims.x);
region.y.end = region.y.start + UiScalar::abs(dims.y);
region
}
fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText {
fn render(&mut self, painter: &mut Painter) -> RenderedText {
let width = if self.attrs.wrap {
Some(ctx.px_size().x)
Some(painter.px_size().x)
} else {
None
};
// The atlas generation is part of the cache key, not a separate
// invalidation path: a `RenderedText` is only meaningful against the
// atlas its glyphs were placed in, and a renderer rebuild clears
// that atlas out from under every widget at once
// (`GlyphAtlas::clear`). Without this the text drawn before the
// rebuild is re-emitted with the old atlas's coordinates and comes
// back as fragments of whatever now occupies them.
let generation = painter.atlas_generation();
if width == self.width
&& let Some(tex) = &self.tex
&& tex.generation == generation
&& !self.attrs.changed
&& !self.buf.changed
{
return tex.clone();
}
self.width = width;
let font_system = &mut ctx.text.font_system;
self.attrs.apply(font_system, &mut self.buf, width);
self.buf.shape_until_scroll(font_system, false);
let tex = ctx.draw_text(&mut self.buf, &self.attrs);
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
// (docs/RUST.md's review, D1): one line per text *shape* (a cache
// miss), unconditional, is many per frame while rows compose --
// see `android::view::IrisViewPeer::render`'s own doc for the same
// finding on its two per-frame lines.
if crate::diagnostics::trace_enabled() {
log::debug!(
target: "iris::frame",
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
self.buf.text().chars().count(),
tex.glyphs.len(),
tex.size,
);
}
self.tex = Some(tex.clone());
self.attrs.changed = false;
self.buf.changed = false;
@@ -80,98 +100,51 @@ impl TextView {
pub fn tex(&self) -> Option<&RenderedText> {
self.tex.as_ref()
}
pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
if let Some(hint) = &self.hint
&& let [line] = &self.buf.lines[..]
&& line.text().is_empty()
/// Draws within `painter.region()` and reports the size used -- what
/// `desired_width`/`desired_height` used to answer separately, folded
/// into the one draw (LAYOUT.md section 4): the shaped layout this
/// reads is already memoized by width in `render`, so a second call at
/// the same width (a redraw with nothing else changed) is a cache hit,
/// not a re-shape.
pub fn draw(&mut self, painter: &mut Painter) -> Size {
let tex = self.render(painter);
if self.is_blank()
&& let Some(hint) = &self.hint
{
ctx.width(hint)
} else {
Len::abs(self.render(ctx).size.x)
return painter.widget(hint);
}
}
pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
if let Some(hint) = &self.hint
&& let [line] = &self.buf.lines[..]
&& line.text().is_empty()
{
ctx.height(hint)
} else {
Len::abs(self.render(ctx).size.y)
}
}
pub fn draw(&mut self, painter: &mut Painter) -> UiRegion {
let tex = self.render(&mut painter.size_ctx());
let region = self.tex_region(&tex);
if let Some(hint) = &self.hint
&& let [line] = &self.buf.lines[..]
&& line.text().is_empty()
{
painter.widget(hint);
} else {
painter.texture_within(&tex.handle, region);
}
region
let region = tex.size.align(self.align);
let within = region.within(&painter.region());
painter.glyphs(&tex, within);
Size::abs(tex.size)
}
pub fn content(&self) -> String {
self.buf
.lines
.iter()
.map(|l| l.text())
.collect::<Vec<_>>()
.join("\n")
self.buf.text().to_string()
}
}
impl Text {
pub fn new(content: impl Into<String>) -> Self {
let attrs = TextAttrs::default();
let buf = TextBuffer::new_empty(Metrics::new(attrs.font_size, attrs.line_height));
let content: String = content.into();
Self {
content: content.into().into(),
view: TextView::new(buf, attrs, None),
view: TextView::new(TextBuffer::new(&content), TextAttrs::default(), None),
content: content.into(),
}
}
fn update_buf(&mut self, ctx: &mut SizeCtx) {
fn update_buf(&mut self) {
if self.content.changed {
self.content.changed = false;
self.view.buf.set_text(
&mut ctx.text.font_system,
&self.content,
&Attrs::new().family(self.view.attrs.family),
SHAPING,
None,
);
self.view.buf.set_text(self.content.as_str());
}
}
}
impl Widget for Text {
fn draw(&mut self, painter: &mut Painter) {
self.update_buf(&mut painter.size_ctx());
self.view.draw(painter);
fn draw(&mut self, painter: &mut Painter) -> Size {
self.update_buf();
self.view.draw(painter)
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
self.update_buf(ctx);
self.view.desired_width(ctx)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
self.update_buf(ctx);
self.view.desired_height(ctx)
}
}
pub fn sort_cursors(a: Cursor, b: Cursor) -> (Cursor, Cursor) {
let start = a.min(b);
let end = a.max(b);
(start, end)
}
pub fn edit_line(line: &mut BufferLine, text: String) {
line.set_text(text, line.ending(), line.attrs_list().clone());
}
impl Deref for Text {
@@ -201,3 +174,55 @@ impl DerefMut for TextView {
&mut self.attrs
}
}
#[cfg(test)]
mod tests {
use crate::layout_tests::TestRsc;
use crate::prelude::*;
/// A renderer rebuild empties the glyph atlas under every widget at
/// once (`iris_core::GlyphAtlas::clear`, called from
/// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything
/// still holding a `RenderedText` from before then owns UV rectangles
/// into a texture that no longer exists -- what Iris photographed on
/// 2026-09-06 as every pre-resume glyph coming back as fragments while
/// the text drawn after the resume was perfect.
///
/// The check is the atlas repopulating: `TextView::render`'s cache
/// short-circuits before `TextData::place`, so without the generation
/// in its key the second frame rasterises nothing and the atlas stays
/// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is
/// the same finding from the submission side.)
#[test]
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let root = wtext("hello there")
.size(18)
.color(UiColor::WHITE)
.add_strong(&mut rsc)
.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let rasterised = rsc.ui.text.atlas.glyph_count();
assert!(rasterised > 0, "the first frame rasterised no glyphs");
// Exactly what the new-renderer branch does, in order: empty the
// atlas, then redraw everything (`resize` is what marks the tree
// for a full redraw, and a real `surface_changed` always calls it).
rsc.ui.text.atlas.clear();
assert_eq!(rsc.ui.text.atlas.glyph_count(), 0);
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.text.atlas.glyph_count(),
rasterised,
"the second frame re-emitted its cached glyphs instead of \
re-rendering them against the fresh atlas"
);
}
}