From 53e7ccc46eb1662ebbc7df86655e12ccaf0d450a Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Fri, 18 Sep 2026 09:55:09 +0200 Subject: [PATCH] =?UTF-8?q?Keep=20the=20GLES=20path=20when=20the=20scale?= =?UTF-8?q?=20arrives=20first,=20stop=20re-rendering=20what=20does=20not?= =?UTF-8?q?=20change,=20and=20let=20the=20compositor=20skip=20what=20the?= =?UTF-8?q?=20surface=20covers=20Measured=20on=20a=20Librem=205=20(Cortex-?= =?UTF-8?q?A53,=20GC7000Lite,=20720=C3=971440=20at=20scale=202)=20with=20a?= =?UTF-8?q?=20frame=20trace=20on=20both=20sides=20of=20the=20socket:=20the?= =?UTF-8?q?=20shell's=20main=20surface=20repainted=20in=20110=20to=20136?= =?UTF-8?q?=20ms,=20quick=20settings=20drew=20at=2020=20fps=20with=20the?= =?UTF-8?q?=20GPU=20saturated,=20and=20every=20panel=20open=20uploaded=20i?= =?UTF-8?q?ts=20wallpaper=20and=20icons=20again.=20Most=20of=20it=20was=20?= =?UTF-8?q?the=20toolkit=20doing=20work=20the=20frame=20did=20not=20need.?= =?UTF-8?q?=20Backend=20choice.=20When=20the=20compositor=20announces=20th?= =?UTF-8?q?e=20output=20scale=20before=20the=20first=20`configure`,=20`sca?= =?UTF-8?q?le=5Ffactor=5Fchanged`=20allocated=20an=20SHM=20pool=20for=20th?= =?UTF-8?q?e=20surface=20and=20`on=5Fconfigure`=20took=20that=20pool=20as?= =?UTF-8?q?=20the=20sign=20that=20the=20software=20path=20had=20been=20cho?= =?UTF-8?q?sen,=20locking=20the=20surface=20to=20tiny-skia=20for=20its=20w?= =?UTF-8?q?hole=20life.=20Every=20ltk=20main=20surface=20on=20a=20scale-2?= =?UTF-8?q?=20phone=20ran=20in=20software=20while=20its=20overlays,=20whic?= =?UTF-8?q?h=20inherit=20the=20scale=20from=20the=20main=20and=20never=20r?= =?UTF-8?q?eceive=20that=20event,=20ran=20on=20the=20GPU.=20The=20scale=20?= =?UTF-8?q?handler=20now=20only=20reallocates=20a=20pool=20that=20already?= =?UTF-8?q?=20exists;=20before=20the=20first=20configure=20neither=20path?= =?UTF-8?q?=20is=20chosen.=20Wallpapers.=20`WallpaperBundle::for=5Fsize`?= =?UTF-8?q?=20returned=20the=20crop=20at=20the=20source=20resolution,=20a?= =?UTF-8?q?=201103=C3=972160=20slice=20of=20a=204K=20image=20for=20a=20720?= =?UTF-8?q?=C3=971440=20panel,=2010=20MB=20uploaded=20per=20texture=20and?= =?UTF-8?q?=20a=20hundred=20milliseconds=20each=20time.=20The=20crop=20is?= =?UTF-8?q?=20now=20downscaled=20to=20the=20surface=20(bilinear,=20once=20?= =?UTF-8?q?per=20size,=20cached),=20also=20for=20landscape=20surfaces=20sm?= =?UTF-8?q?aller=20than=20the=20source.=20Viewport.=20`Element::Viewport`?= =?UTF-8?q?=20created=20a=20fresh=20sub-canvas=20every=20frame,=20on=20GLE?= =?UTF-8?q?S=20a=20framebuffer,=20a=202048=C2=B2=20glyph=20atlas=20and=20e?= =?UTF-8?q?mpty=20image=20and=20glyph=20caches,=20so=20a=20panel=20clipped?= =?UTF-8?q?=20to=20a=20height=20that=20animates=20re-uploaded=20its=20wall?= =?UTF-8?q?paper=20and=20re-rasterised=20every=20glyph=20on=20every=20fram?= =?UTF-8?q?e.=20Without=20a=20bottom=20fade=20the=20clip=20is=20now=20a=20?= =?UTF-8?q?scissor=20on=20the=20parent=20canvas=20(a=20mask=20on=20the=20s?= =?UTF-8?q?oftware=20backend):=20no=20offscreen=20pass,=20no=20blit,=20and?= =?UTF-8?q?=20the=20child's=20textures=20stay=20in=20the=20parent's=20cach?= =?UTF-8?q?es.=20`local=5Fviewport`=20is=20honoured=20through=20a=20tempor?= =?UTF-8?q?ary=20override=20of=20the=20layout=20viewport.=20Viewports=20wi?= =?UTF-8?q?th=20a=20fade=20keep=20their=20sub-canvas,=20which,=20like=20th?= =?UTF-8?q?e=20scroll=20sub-canvases,=20is=20now=20kept=20across=20frames?= =?UTF-8?q?=20and=20resized=20in=20place=20instead=20of=20reallocated=20wh?= =?UTF-8?q?en=20its=20size=20changes.=20Caches.=20The=20image=20texture=20?= =?UTF-8?q?cache=20moves=20from=20the=20canvas=20to=20the=20GL=20context:?= =?UTF-8?q?=20every=20canvas=20and=20sub-canvas=20shares=20one=20content-k?= =?UTF-8?q?eyed=20LRU=20(32=20MiB),=20so=20an=20overlay=20or=20subsurface?= =?UTF-8?q?=20that=20is=20destroyed=20and=20recreated=20finds=20its=20imag?= =?UTF-8?q?es=20resident.=20A=20GLES=20canvas=20dropped=20with=20an=20over?= =?UTF-8?q?lay=20is=20pooled=20in=20`AppData::canvas=5Fpool`=20and=20hande?= =?UTF-8?q?d=20to=20the=20next=20overlay,=20with=20its=20compiled=20progra?= =?UTF-8?q?ms=20and=20glyph=20atlas,=20which=20took=20130=20to=20290=20ms?= =?UTF-8?q?=20to=20build=20on=20that=20GPU.=20Subsurface=20slots=20keep=20?= =?UTF-8?q?their=20scroll=20and=20viewport=20sub-canvases=20between=20rast?= =?UTF-8?q?ers.=20Surfaces.=20A=20`container.surface()`=20with=20shadows?= =?UTF-8?q?=20costs=20a=20Gaussian=20drop-shadow=20pass,=20a=20fill=20and?= =?UTF-8?q?=20one=20pass=20per=20inset=20shadow,=20all=20full-quad=20fragm?= =?UTF-8?q?ent=20shaders;=20a=20dozen=20glass=20cards=20were=2040=20ms=20o?= =?UTF-8?q?f=20GPU=20per=20frame=20for=20chrome=20that=20never=20changes.?= =?UTF-8?q?=20`Canvas::fill=5Fsurface`=20on=20GLES=20now=20renders=20the?= =?UTF-8?q?=20chrome=20once=20per=20size,=20corners=20and=20style=20into?= =?UTF-8?q?=20a=20texture=20held=20by=20the=20context=20cache=20(`gles=5Fr?= =?UTF-8?q?ender/surface=5Fcache.rs`,=2016=20MiB=20LRU)=20and=20blits=20it?= =?UTF-8?q?,=20drawing=20directly=20only=20for=20surfaces=20with=20`PlusLi?= =?UTF-8?q?ghter`=20or=20`Overlay`=20insets,=20which=20depend=20on=20the?= =?UTF-8?q?=20backdrop.=20Opaque=20regions.=20ltk=20never=20called=20`wl?= =?UTF-8?q?=5Fsurface.set=5Fopaque=5Fregion`,=20so=20the=20compositor=20bl?= =?UTF-8?q?ended=20every=20ltk=20surface=20and=20repainted=20everything=20?= =?UTF-8?q?underneath=20a=20full-screen=20homescreen.=20A=20surface=20whos?= =?UTF-8?q?e=20`background=5Fcolor`=20is=20opaque=20now=20declares=20itsel?= =?UTF-8?q?f=20opaque,=20and=20the=20new=20`App::overlay=5Fopaque=5Fregion?= =?UTF-8?q?(=20id=20)`=20lets=20an=20overlay=20declare=20the=20physical=20?= =?UTF-8?q?rects=20it=20paints=20opaque,=20such=20as=20a=20panel=20column?= =?UTF-8?q?=20over=20an=20opaque=20wallpaper.=20`sync=5Fopaque=5Fregion`?= =?UTF-8?q?=20sends=20the=20rects=20only=20when=20they=20change.=20Frame?= =?UTF-8?q?=20pacing.=20`eglSwapBuffers`=20blocked=2013=20to=2037=20ms=20p?= =?UTF-8?q?er=20frame=20waiting=20for=20Mesa's=20own=20throttle=20callback?= =?UTF-8?q?;=20ltk=20paces=20itself=20on=20`wl=5Fsurface.frame`,=20so=20`m?= =?UTF-8?q?ake=5Fcurrent`=20sets=20the=20swap=20interval=20to=200.=20A=20s?= =?UTF-8?q?ubsurface=20move=20is=20now=20applied=20only=20when=20its=20par?= =?UTF-8?q?ent=20has=20no=20frame=20callback=20outstanding,=20and=20the=20?= =?UTF-8?q?parent=20commit=20that=20applies=20it=20requests=20the=20next?= =?UTF-8?q?=20callback:=20a=20drag=20commits=20once=20per=20vblank=20with?= =?UTF-8?q?=20the=20latest=20finger=20position=20instead=20of=20once=20per?= =?UTF-8?q?=20input=20sample,=20without=20the=20app=20throttling=20samples?= =?UTF-8?q?=20itself.=20While=20the=20app=20reports=20`subsurface=5Fmotion?= =?UTF-8?q?=5Fonly`,=20the=20main's=20callback,=20and=20the=20callback=20o?= =?UTF-8?q?f=20an=20overlay=20that=20hosts=20a=20subsurface,=20no=20longer?= =?UTF-8?q?=20force=20a=20full=20repaint;=20they=20mark=20the=20surface=20?= =?UTF-8?q?and=20the=20reconcile=20keeps=20the=20cadence=20with=20a=20bare?= =?UTF-8?q?=20commit=20when=20nothing=20moved.=20Swipe=20releases=20arm=20?= =?UTF-8?q?a=20settle=20the=20same=20way=20instead=20of=20dirtying=20every?= =?UTF-8?q?=20cache.=20Two=20hooks,=20`on=5Foverlay=5Fframe=5Fcommitted`?= =?UTF-8?q?=20and=20`on=5Fsubsurface=5Fcommitted`,=20let=20an=20app=20hand?= =?UTF-8?q?=20a=20panel=20over=20between=20its=20host=20and=20a=20sliding?= =?UTF-8?q?=20subsurface=20on=20real=20commits=20rather=20than=20loop=20ti?= =?UTF-8?q?cks.=20Lines.=20A=20diagonal=20`draw=5Fline`=20on=20GLES=20stam?= =?UTF-8?q?ped=20one=20`fill=5Frect`=20per=20pixel=20of=20length,=20190=20?= =?UTF-8?q?draw=20calls=20for=20a=20spinner=20and=20over=202000=20for=20a?= =?UTF-8?q?=20pattern-lock=20stroke;=20it=20is=20now=20a=20single=20rotate?= =?UTF-8?q?d=20quad=20through=20the=20rect=20shader,=20with=20round=20caps?= =?UTF-8?q?=20and=20antialiased=20edges.=20A=20pixel=20test=20covers=20the?= =?UTF-8?q?=20scissor=20viewport,=20`for=5Fsize`=20gets=20a=20downscale=20?= =?UTF-8?q?test,=20and=20`docs/architecture.md`,=20`docs/widgets.md`=20and?= =?UTF-8?q?=20`docs/backends.md`=20describe=20the=20new=20hook,=20the=20vi?= =?UTF-8?q?ewport=20paths,=20the=20shared=20caches=20and=20the=20line=20ra?= =?UTF-8?q?steriser.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 7 +- docs/backends.md | 2 +- docs/widgets.md | 4 +- src/app.rs | 14 +++ src/core.rs | 5 + src/draw/chrome.rs | 41 +++++++ src/draw/gles.rs | 6 +- src/draw/layout.rs | 68 +++++++++++- src/draw/mod.rs | 7 ++ src/draw/software.rs | 6 +- src/egl_context.rs | 3 + src/event_loop/app_data.rs | 2 + src/event_loop/frame.rs | 14 ++- src/event_loop/handlers.rs | 31 +++--- src/event_loop/overlays_reconcile.rs | 17 ++- src/event_loop/run.rs | 1 + src/event_loop/subsurface.rs | 112 +++++++++++++++++-- src/event_loop/surface.rs | 10 ++ src/gles_render/clip.rs | 2 +- src/gles_render/image.rs | 155 +++++++++++++++++++++------ src/gles_render/mod.rs | 15 +-- src/gles_render/primitives.rs | 65 +++++++++-- src/gles_render/setup.rs | 9 +- src/gles_render/surface_cache.rs | 130 ++++++++++++++++++++++ src/input/dispatch/outcomes.rs | 39 ++++--- src/render/mod.rs | 92 ++++++++++++++++ src/wallpaper.rs | 31 +++++- tests/render_pixels.rs | 30 +++++- 28 files changed, 808 insertions(+), 110 deletions(-) create mode 100644 src/gles_render/surface_cache.rs diff --git a/docs/architecture.md b/docs/architecture.md index 771fa87..461fdc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,8 @@ In practice, that model is easiest to adopt in three steps: - `shell_mode()` → `ShellMode::Layer( Layer::Background | Bottom | Top | Overlay )`. - `layer_anchor()`, `layer_size()`, `exclusive_zone()`, `keyboard_exclusive()` — the layer-shell knobs. -- `background_color()` → `Color::rgba( 0, 0, 0, 0 )` for transparent surfaces (panels, OSDs). +- `background_color()` → `Color::rgba( 0, 0, 0, 0 )` for transparent surfaces (panels, OSDs). An opaque colour also declares the whole surface opaque to the compositor, which then neither blends it nor repaints what lies underneath — return one whenever the view covers the surface (a wallpaper-backed homescreen). +- `overlay_opaque_region( id )` → the physical rects of an overlay that are fully opaque this frame (a panel column painted over an opaque wallpaper), for the same compositor savings on a surface whose background is transparent. `None` (default) declares nothing. **Implement when external state matters** @@ -282,7 +283,9 @@ The cheap things and the expensive things, in rough order: - *Avoid in `view()`*: cloning large `Vec` image buffers. `img_widget` takes an `Arc>`; build the `Arc` once at load time and clone *the Arc*, not the bytes. - *Avoid `is_animating() = true` when nothing is moving.* It pegs the loop at the full animation rate and burns battery on the mobile target. - *Lower `poll_interval()` is not free.* Crustace polls every 30 s because the clock only shows HH:MM. If your UI shows seconds, `Some(Duration::from_secs(1))` is fine; if it shows nothing time-sensitive, leave it `None`. -- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper. +- *Scroll viewports own a sub-canvas.* They're slightly more expensive to draw than a plain column. Use them when you need clipping or actual scrolling, not as a wrapper. A plain `viewport` without `fade_bottom` is cheaper: it clips with a scissor on the parent canvas and adds no offscreen pass, so it is the right wrapper for a panel whose visible height animates. +- *Themed surfaces with shadows are pre-rendered on GLES.* A `container.surface(...)` whose slot carries outer or inset shadows (all with `Normal` blend) is drawn once per (size, corners, style) into a texture held by the context's cache and blitted afterwards, so a panel of a dozen glass cards costs a dozen textured quads per frame instead of six shader passes per card. Sliders and surfaces with `PlusLighter` / `Overlay` insets keep the direct path. +- *Image textures are cached per GL context, not per canvas.* On the GLES backend every canvas and sub-canvas of a process shares one content-keyed texture cache (32 MiB, LRU), so an overlay or subsurface that is destroyed and recreated finds its icons and wallpaper resident; destroyed overlay canvases are pooled and reused too, shaders and glyph atlas included. Keep image buffers stable (`Arc` clones) so the fingerprint hits. - *GPU vs software*: the GLES path is selected automatically when EGL is available, with no API-level difference for the application. The two backends are not yet pixel-identical — gradients and the shadow / backdrop pipeline degrade under software; the authoritative list of gaps is the README's *Backend Differences* section. When a redraw feels sluggish: add a one-line print at the top of `view()` and confirm it's not being called more often than expected. The single most common mistake is leaving `is_animating()` returning `true` after the animation finished. diff --git a/docs/backends.md b/docs/backends.md index 312207d..acd455c 100644 --- a/docs/backends.md +++ b/docs/backends.md @@ -8,7 +8,7 @@ The two backends are kept at visual parity for the common primitives — solid f | Capability | Software | GLES | Notes | | --- | --- | --- | --- | -| Solid fills, strokes, lines (`fill_rect`, `stroke_rect`, `draw_line`) | Yes | Yes | Visual parity. | +| Solid fills, strokes, lines (`fill_rect`, `stroke_rect`, `draw_line`) | Yes | Yes | Visual parity. A diagonal `draw_line` is one rotated quad on GLES (antialiased, round caps); software strokes a path. | | Text (`draw_text`, `draw_text_with_font`) | Yes | Yes | Both snap pen positions to integer pixels; glyph caches differ (per-canvas hashmap with eviction vs. shared atlas texture) but output is at parity. | | Images (`draw_image_data`) | Yes | Yes | Both snap destinations to integer pixels. GLES caches uploaded textures content-keyed, bounded to 32 MiB with LRU eviction; software draws directly from the buffer. | | External GL texture (`draw_external_texture`, `External::texture`) | No-op | Yes | Software has no GL state to sample from. Use `External::cpu` for a backend-independent immediate-mode draw hook. | diff --git a/docs/widgets.md b/docs/widgets.md index 4fb1e4b..7659194 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -644,7 +644,9 @@ viewport( panel_view ) # } ``` -By default the clip's sub-canvas inherits the root layout viewport, so viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s inside resolve exactly as they would outside the clip — the right behaviour for scroll-like clipping. `local_viewport()` opts out: the child resolves those `Length`s against the viewport's own rect instead. Use it for a fixed-size floating mini-UI — a phone-shaped panel pinned to a corner of a desktop-wide surface — whose content is calibrated against the panel rect and must not scale with the surface hosting it. +By default the clip inherits the root layout viewport, so viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s inside resolve exactly as they would outside the clip — the right behaviour for scroll-like clipping. `local_viewport()` opts out: the child resolves those `Length`s against the viewport's own rect instead. Use it for a fixed-size floating mini-UI — a phone-shaped panel pinned to a corner of a desktop-wide surface — whose content is calibrated against the panel rect and must not scale with the surface hosting it. + +Without `fade_bottom` the clip is a plain scissor (a mask on the software backend) on the parent canvas: no offscreen pass, no blit, and the child's images and glyphs stay in the parent's caches. A viewport whose height changes every frame — a panel sliding in — therefore costs no more than drawing its child. With `fade_bottom` the child renders into a sub-canvas that is kept across frames and resized in place when the viewport's size changes. **See also**: the slide-in panel recipe in [`docs/cookbook.md`](./cookbook.md#slide-in-panel). diff --git a/src/app.rs b/src/app.rs index a707f03..4f129f6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -568,6 +568,15 @@ pub trait App: 'static /// the main frame that replaces it has been sent. fn on_main_frame_committed( &mut self ) {} + /// Called every time a buffer for the overlay `id` has been rendered and + /// committed. + fn on_overlay_frame_committed( &mut self, _id: OverlayId ) {} + + /// Called every time the content of the subsurface `id` has been + /// rasterised and committed (first raster, resize or a new + /// `content_version`), before the parent commit that maps it. + fn on_subsurface_committed( &mut self, _id: SubsurfaceId ) {} + /// Called when the surface is asked to close (compositor request, titlebar button, /// layer-shell closed event). Return `true` to allow the application to exit, /// or `false` to cancel. @@ -953,6 +962,11 @@ pub trait App: 'static /// Return `None` (default) to receive input everywhere. fn input_region( &self ) -> Option> { None } + /// Rects (physical layout pixels) of overlay `id` that are fully opaque + /// this frame, so the compositor can skip blending them and repainting + /// what lies underneath. `None` (default) declares nothing opaque. + fn overlay_opaque_region( &self, _id: OverlayId ) -> Option> { None } + /// Return `Some(( title, app_id ))` to force an XDG toplevel window instead of /// layer-shell overlay. The compositor will display the title in the title bar /// and use the app_id for taskbar/icon matching. Return `None` (default) to diff --git a/src/core.rs b/src/core.rs index fd60769..48e29a2 100644 --- a/src/core.rs +++ b/src/core.rs @@ -101,6 +101,7 @@ pub struct UiSurface selection_anchor: HashMap, scroll_offsets: HashMap, scroll_canvases: HashMap, + viewport_canvases: HashMap, scroll_navigable_items: HashMap>, content_dirty: bool, } @@ -228,6 +229,7 @@ impl UiSurface selection_anchor: HashMap::new(), scroll_offsets: HashMap::new(), scroll_canvases: HashMap::new(), + viewport_canvases: HashMap::new(), scroll_navigable_items: HashMap::new(), content_dirty: true, } @@ -401,6 +403,8 @@ impl UiSurface scroll_offsets: std::mem::take( &mut self.scroll_offsets ), scroll_rects: Vec::new(), scroll_canvases: std::mem::take( &mut self.scroll_canvases ), + viewport_canvases: std::mem::take( &mut self.viewport_canvases ), + viewport_seq: 0, scroll_navigable_items: std::mem::take( &mut self.scroll_navigable_items ), scroll_clipped: Vec::new(), previous_widget_rects: self.widget_rects.clone(), @@ -450,6 +454,7 @@ impl UiSurface // getter together with a `scroll_by( flat_idx, dy )` mutator and start // keeping the field again. self.scroll_canvases = ctx.scroll_canvases; + self.viewport_canvases = ctx.viewport_canvases; self.scroll_navigable_items = ctx.scroll_navigable_items; self.content_dirty = false; diff --git a/src/draw/chrome.rs b/src/draw/chrome.rs index 3e03891..57e565b 100644 --- a/src/draw/chrome.rs +++ b/src/draw/chrome.rs @@ -12,6 +12,47 @@ use smithay_client_toolkit::reexports::client::protocol::wl_surface::WlSurface; use crate::render::Canvas; use crate::types::{ Color, Rect }; +/// Declare the opaque part of the surface: the whole of it when the +/// background is opaque, else the app's `region` (physical rects), so the +/// compositor can skip blending it and repainting what lies underneath. +/// `applied` remembers the rects last sent. +pub( crate ) fn sync_opaque_region( + wl_surface: &WlSurface, + compositor: &CompositorState, + applied: &mut Option>, + bg: Color, + region: Option<&[Rect]>, + width: u32, + height: u32, + scale: u32, +) +{ + let s = scale.max( 1 ) as f32; + let rects: Vec = if bg.a >= 1.0 + { + vec![ Rect { x: 0.0, y: 0.0, width: width as f32 * s, height: height as f32 * s } ] + } else { + region.map( |r| r.to_vec() ).unwrap_or_default() + }; + if applied.as_ref() == Some( &rects ) { return; } + if rects.is_empty() + { + wl_surface.set_opaque_region( None ); + } else if let Ok( wl_region ) = Region::new( compositor ) { + for r in &rects + { + wl_region.add( + ( r.x / s ).round() as i32, + ( r.y / s ).round() as i32, + ( r.width / s ).round() as i32, + ( r.height / s ).round() as i32, + ); + } + wl_surface.set_opaque_region( Some( wl_region.wl_region() ) ); + } + *applied = Some( rects ); +} + /// Paint the client-side title bar (close button, divider, title text). /// /// Returns the close button rect in physical pixels so the caller can diff --git a/src/draw/gles.rs b/src/draw/gles.rs index 1e8aaf4..61aed50 100644 --- a/src/draw/gles.rs +++ b/src/draw/gles.rs @@ -31,7 +31,7 @@ use crate::types::{ Color, Rect }; use crate::widget::Element; use super::{ build_draw_ctx, commit_draw_ctx, layout_and_draw }; -use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar }; +use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar, sync_opaque_region }; /// GPU full-redraw path. Mirrors [`super::software::draw_surface_full`] /// but writes into the EGL surface's persistent FBO instead of an SHM @@ -51,6 +51,7 @@ pub( crate ) fn draw_surface_full_gpu( view: &Element, bg: Color, input_region: Option<&[Rect]>, + opaque_region: Option<&[Rect]>, debug_layout: bool, pw: u32, ph: u32, @@ -114,6 +115,7 @@ pub( crate ) fn draw_surface_full_gpu( ss.content_dirty = false; let wl_surface = ss.surface.wl_surface(); + sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); apply_input_region( wl_surface, compositor, input_region, scale ); // Frame callback request must precede the implicit commit done by // `swap_buffers_with_damage`, so the compositor can attach it to this @@ -138,6 +140,7 @@ pub( crate ) fn draw_surface_partial_gpu( view: &Element, bg: Color, input_region: Option<&[Rect]>, + opaque_region: Option<&[Rect]>, dirty_rects: Vec, pw: u32, ph: u32, @@ -187,6 +190,7 @@ pub( crate ) fn draw_surface_partial_gpu( ss.content_dirty = false; let wl_surface = ss.surface.wl_surface(); + sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); apply_input_region( wl_surface, compositor, input_region, scale ); request_frame( wl_surface ); // Convert top-left dirty rects to EGL bottom-left coords for the diff --git a/src/draw/layout.rs b/src/draw/layout.rs index a9040a6..5fec040 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -259,7 +259,7 @@ pub( crate ) fn layout_and_draw( let sw = (rect.width.ceil() as u32).max( 1 ); let sh = (rect.height.ceil() as u32).max( 1 ); let mut sub = ctx.scroll_canvases.remove( &my_idx ) - .filter( |c| c.size() == ( sw, sh ) ) + .map( |mut c| { if c.size() != ( sw, sh ) { c.resize( sw, sh ); } c } ) .unwrap_or_else( || canvas.sub_canvas( sw, sh ) ); sub.clear(); @@ -351,13 +351,76 @@ pub( crate ) fn layout_and_draw( next_idx } + // Without a fade the clip is a plain scissor on the same canvas: no + // offscreen pass, no blit, and the child's textures stay in the + // parent's caches. + Element::Viewport( v ) if v.fade_bottom <= 0.0 => + { + let child_h = v.child.preferred_size( rect.width, canvas ).1; + let effective_h = child_h.max( rect.height ); + let saved_clip = canvas.clip_bounds(); + let clip = if saved_clip.is_empty() + { + rect + } else { + let ( mut x0, mut y0, mut x1, mut y1 ) = ( f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY ); + for r in &saved_clip + { + x0 = x0.min( r.x ); + y0 = y0.min( r.y ); + x1 = x1.max( r.x + r.width ); + y1 = y1.max( r.y + r.height ); + } + clamp_rect_to( rect, Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) + }; + canvas.set_clip_rects( &[ clip ] ); + let prev_viewport = if v.local_viewport + { + canvas.layout_viewport_override( Some( ( rect.width, rect.height ) ) ) + } else { + None + }; + + let child_rect = Rect { x: rect.x, y: rect.y, width: rect.width, height: effective_h }; + let rects_before = ctx.widget_rects.len(); + let scroll_rects_before = ctx.scroll_rects.len(); + let next_idx = layout_and_draw::( v.child.as_ref(), canvas, child_rect, ctx, flat_idx ); + + if v.local_viewport { canvas.layout_viewport_override( prev_viewport ); } + canvas.set_clip_rects( &saved_clip ); + + let new_rects: Vec> = ctx.widget_rects.drain( rects_before.. ).collect(); + for mut w in new_rects + { + w.paint_rect = clamp_rect_to( w.paint_rect, rect ); + if w.rect.y + w.rect.height > rect.y && w.rect.y < rect.y + rect.height + { + ctx.widget_rects.push( w ); + } + } + let new_scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )> = + ctx.scroll_rects.drain( scroll_rects_before.. ).collect(); + for ( r, idx, ax ) in new_scroll_rects + { + let clamped = clamp_rect_to( r, rect ); + if clamped.width > 0.0 && clamped.height > 0.0 + { + ctx.scroll_rects.push( ( clamped, idx, ax ) ); + } + } + next_idx + } Element::Viewport( v ) => { let child_h = v.child.preferred_size( rect.width, canvas ).1; let effective_h = child_h.max( rect.height ); let vw = ( rect.width.ceil() as u32 ).max( 1 ); let vh = ( rect.height.ceil() as u32 ).max( 1 ); - let mut sub = canvas.sub_canvas( vw, vh ); + let seq = ctx.viewport_seq; + ctx.viewport_seq += 1; + let mut sub = ctx.viewport_canvases.remove( &seq ) + .map( |mut c| { if c.size() != ( vw, vh ) { c.resize( vw, vh ); } c } ) + .unwrap_or_else( || canvas.sub_canvas( vw, vh ) ); if v.local_viewport { sub.set_local_layout_viewport(); } sub.clear(); @@ -397,6 +460,7 @@ pub( crate ) fn layout_and_draw( } canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom ); + ctx.viewport_canvases.insert( seq, sub ); next_idx } Element::RichText( r ) => diff --git a/src/draw/mod.rs b/src/draw/mod.rs index ecb14ed..d3e93c7 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -66,6 +66,10 @@ pub( crate ) struct DrawCtx pub scroll_offsets: HashMap, pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>, pub scroll_canvases: HashMap, + /// Sub-canvases of `Viewport` elements, keyed by their order of + /// appearance in the walk (a viewport owns no flat index of its own). + pub viewport_canvases: HashMap, + pub viewport_seq: usize, /// Per-scroll navigation map: list of `(flat_idx, content_y, height)` /// for every interactive item the scroll's child laid out, in /// document order, **including items currently scrolled off-screen**. @@ -120,6 +124,8 @@ pub( crate ) fn build_draw_ctx( scroll_offsets: std::mem::take( &mut frame.scroll_offsets ), scroll_rects: Vec::new(), scroll_canvases: std::mem::take( &mut frame.scroll_canvases ), + viewport_canvases: std::mem::take( &mut frame.viewport_canvases ), + viewport_seq: 0, scroll_navigable_items: std::mem::take( &mut frame.scroll_navigable_items ), scroll_clipped: Vec::new(), previous_widget_rects: frame.widget_rects.clone(), @@ -148,6 +154,7 @@ pub( crate ) fn commit_draw_ctx( frame.widget_rects = ctx.widget_rects; frame.scroll_rects = ctx.scroll_rects; frame.scroll_canvases = std::mem::take( &mut ctx.scroll_canvases ); + frame.viewport_canvases = std::mem::take( &mut ctx.viewport_canvases ); frame.cursor_state = ctx.cursor_state; frame.selection_anchor = ctx.selection_anchor; frame.scroll_offsets = ctx.scroll_offsets; diff --git a/src/draw/software.rs b/src/draw/software.rs index 0a45039..0b56b0b 100644 --- a/src/draw/software.rs +++ b/src/draw/software.rs @@ -30,7 +30,7 @@ use crate::types::{ Color, Rect }; use crate::widget::Element; use super::{ build_draw_ctx, commit_draw_ctx, compute_damage, layout_and_draw }; -use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar }; +use super::chrome::{ apply_input_region, draw_fallback_banner, draw_titlebar, sync_opaque_region }; /// Full redraw path: clear the canvas, run layout+draw for every widget, then /// emit either tight per-widget damage or a single full-surface damage rect @@ -41,6 +41,7 @@ pub( crate ) fn draw_surface_full( view: &Element, bg: Color, input_region: Option<&[Rect]>, + opaque_region: Option<&[Rect]>, debug_layout: bool, shm_format: wl_shm::Format, swap_rb: bool, @@ -128,6 +129,7 @@ pub( crate ) fn draw_surface_full( canvas.write_to_wayland_buf( canvas_buf, swap_rb ); let wl_surface = ss.surface.wl_surface(); + sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); buffer.attach_to( wl_surface ).expect( "attach" ); if damage_rects.is_empty() @@ -159,6 +161,7 @@ pub( crate ) fn draw_surface_partial( view: &Element, bg: Color, input_region: Option<&[Rect]>, + opaque_region: Option<&[Rect]>, shm_format: wl_shm::Format, swap_rb: bool, dirty_rects: Vec, @@ -221,6 +224,7 @@ pub( crate ) fn draw_surface_partial( canvas.write_to_wayland_buf( canvas_buf, swap_rb ); let wl_surface = ss.surface.wl_surface(); + sync_opaque_region( wl_surface, compositor, &mut ss.opaque_full, bg, opaque_region, ss.width, ss.height, scale ); buffer.attach_to( wl_surface ).expect( "attach" ); for r in &dirty_rects diff --git a/src/egl_context.rs b/src/egl_context.rs index b74506c..e419a96 100644 --- a/src/egl_context.rs +++ b/src/egl_context.rs @@ -290,6 +290,9 @@ impl EglContext Some( surface.surface ), Some( self.context ), ).map_err( |e| format!( "eglMakeCurrent: {e:?}" ) )?; + // ltk paces itself on wl_surface.frame; Mesa's own throttle would + // block eglSwapBuffers until the previous frame's callback. + let _ = self.egl.swap_interval( self.display, 0 ); if self.gl.get().is_none() { diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 9b0a2ce..cc3e800 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -243,6 +243,8 @@ pub struct AppData /// lazily-emitted sliding panel — does not recompile the ~14 shader /// programs (hundreds of ms on a mobile GPU) every time it reappears. pub subsurface_gles_canvas: Option, + /// GLES canvases of destroyed overlays, reused by the next overlay. + pub canvas_pool: Vec, /// Surface currently receiving pointer events (updated on each pointer /// event via its `surface` field). pub pointer_focus: SurfaceFocus, diff --git a/src/event_loop/frame.rs b/src/event_loop/frame.rs index 657be8e..18976b5 100644 --- a/src/event_loop/frame.rs +++ b/src/event_loop/frame.rs @@ -63,6 +63,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool main_view, main_bg, main_region.as_deref(), + None, debug_layout, format, swap_rb, @@ -95,6 +96,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool &view, main_bg, None, + None, debug_layout, format, swap_rb, @@ -117,6 +119,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool } let req_frame = | wl: &WlSurface | { let _ = wl.frame( qh, focus ); }; let bg = Color::rgba( 0.0, 0.0, 0.0, 0.0 ); + let opaque = data.app.overlay_opaque_region( spec.id ); draw_surface::( ss, &data.compositor_state, @@ -124,6 +127,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool &spec.view, bg, spec.input_region.as_deref(), + opaque.as_deref(), debug_layout, format, swap_rb, @@ -131,6 +135,7 @@ pub( crate ) fn draw_frame( data: &mut AppData ) -> bool ); ss.needs_redraw = false; ss.last_draw = std::time::Instant::now(); + data.app.on_overlay_frame_committed( spec.id ); } } @@ -149,6 +154,7 @@ fn draw_surface( view: &Element, bg: Color, input_region: Option<&[Rect]>, + opaque_region: Option<&[Rect]>, debug_layout: bool, shm_format: wl_shm::Format, swap_rb: bool, @@ -184,12 +190,12 @@ fn draw_surface( if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() ) { draw_surface_partial_gpu( - ss, compositor, ctx, view, bg, input_region, + ss, compositor, ctx, view, bg, input_region, opaque_region, dirty_rects, pw, ph, scale, request_frame, ); } else { draw_surface_partial( - ss, compositor, view, bg, input_region, + ss, compositor, view, bg, input_region, opaque_region, shm_format, swap_rb, dirty_rects, pw, ph, scale, request_frame, ); } @@ -200,12 +206,12 @@ fn draw_surface( if let ( Some( ctx ), true ) = ( egl_ctx, ss.egl_surface.is_some() ) { draw_surface_full_gpu( - ss, compositor, ctx, view, bg, input_region, debug_layout, + ss, compositor, ctx, view, bg, input_region, opaque_region, debug_layout, pw, ph, scale, request_frame, ); } else { draw_surface_full( - ss, compositor, view, bg, input_region, debug_layout, + ss, compositor, view, bg, input_region, opaque_region, debug_layout, shm_format, swap_rb, pw, ph, scale, request_frame, ); } diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 9761471..24f1dce 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -70,7 +70,9 @@ fn apply_surface_scale( es.resize( pw as i32, ph as i32 ); // Canvas FBO reallocation is deferred to the draw path: it needs // `eglMakeCurrent` first. - } else { + } else if ss.pool.is_some() { + // Before the first configure neither path is chosen yet; creating a + // pool here would lock the surface to SHM. ss.pool = Some( smithay_client_toolkit::shm::slot::SlotPool::new( ( pw * ph * 4 ) as usize, shm, @@ -811,16 +813,10 @@ impl Dispatch for AppData { if state.app.subsurface_motion_only() { - // The main buffer is unchanged; only the subsurface - // moves (repositioned by the per-frame reconcile). - // Keep the vsync cadence with a bare frame callback + - // commit — no buffer attach, no re-raster. - if let Some( wl ) = state.main.surface.try_wl_surface().cloned() - { - let _ = wl.frame( &state.qh, super::SurfaceFocus::Main ); - wl.commit(); - state.main.frame_pending = true; - } + // The main buffer is unchanged; the subsurface + // reconcile applies the latest move on this tick and + // re-arms the callback. + state.main.frame_tick_due = true; } else { @@ -850,13 +846,22 @@ impl Dispatch for AppData } super::SurfaceFocus::Overlay( id ) => { + let hosts_subsurface = state.subsurfaces.values() + .any( |s| s.parent() == crate::app::SubsurfaceParent::Overlay( id ) ); if let Some( ss ) = state.overlays.get_mut( &id ) { ss.frame_pending = false; if is_animating { - state.overlays_dirty = true; - ss.request_redraw(); + if hosts_subsurface && state.app.subsurface_motion_only() + { + ss.frame_tick_due = true; + } + else + { + state.overlays_dirty = true; + ss.request_redraw(); + } } } } diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index 76bcdaa..2559527 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -51,8 +51,18 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) .collect(); for id in removed { - if let Some( ss ) = data.overlays.remove( &id ) + if let Some( mut ss ) = data.overlays.remove( &id ) { + // A GLES canvas carries compiled programs, the glyph atlas and + // the image cache: keep it for the next overlay instead of + // paying all of that again on every open. + if let Some( canvas ) = ss.canvas.take() + { + if matches!( canvas, crate::render::Canvas::Gles( _ ) ) + { + data.canvas_pool.push( canvas ); + } + } if ss.gesture.long_press_fired { data.main.gesture.long_press_fired = true; @@ -143,7 +153,9 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // across the overlay-mut loop below. let main_widget_rects = &data.main.frame.widget_rects; let app = &data.app; - let overlays_m = &mut data.overlays; + let overlays_m = &mut data.overlays; + let canvas_pool = &mut data.canvas_pool; + let has_egl = data.egl_context.is_some(); for spec in &specs { let resolved_size = resolve_size( &spec.size ); @@ -364,6 +376,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) ss.last_requested_size = resolved_size; ss.last_requested_zone = to_logical_zone( spec.exclusive_zone ); ss.layer_anchor = Some( spec.anchor ); + if has_egl { ss.canvas = canvas_pool.pop(); } overlays_m.insert( spec.id, ss ); } } diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index 6b66de9..f30b025 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -354,6 +354,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> main_output: None, subsurfaces: std::collections::HashMap::new(), subsurface_gles_canvas: None, + canvas_pool: Vec::new(), pointer_focus: SurfaceFocus::Main, keyboard_focus: SurfaceFocus::Main, touch_focus: std::collections::HashMap::new(), diff --git a/src/event_loop/subsurface.rs b/src/event_loop/subsurface.rs index 675b5cb..f9b0cf0 100644 --- a/src/event_loop/subsurface.rs +++ b/src/event_loop/subsurface.rs @@ -58,12 +58,26 @@ pub( crate ) struct SubsurfaceSlot last_version: u64, rastered: bool, parent: SubsurfaceParent, + scroll_canvases: HashMap, + viewport_canvases: HashMap, } impl SubsurfaceSlot { - fn destroy( mut self ) + pub( crate ) fn parent( &self ) -> SubsurfaceParent { + self.parent + } + + fn destroy( mut self, egl: Option<&Arc> ) + { + // GLES sub-canvases free their objects on drop and need the context current. + if let ( Some( ctx ), Some( es ) ) = ( egl, self.egl_surface.as_ref() ) + { + let _ = ctx.make_current( es ); + } + self.scroll_canvases.clear(); + self.viewport_canvases.clear(); // Drop the EGL surface before the wl_surface its wl_egl_window wraps. self.egl_surface = None; self.subsurface.destroy(); @@ -71,6 +85,56 @@ impl SubsurfaceSlot } } +fn focus_of( parent: SubsurfaceParent ) -> super::SurfaceFocus +{ + match parent + { + SubsurfaceParent::Main => super::SurfaceFocus::Main, + SubsurfaceParent::Overlay( id ) => super::SurfaceFocus::Overlay( id ), + } +} + +fn parent_frame_pending( data: &AppData, parent: SubsurfaceParent ) -> bool +{ + match parent + { + SubsurfaceParent::Main => data.main.frame_pending, + SubsurfaceParent::Overlay( id ) => data.overlays.get( &id ).is_some_and( |ss| ss.frame_pending ), + } +} + +/// Request a frame callback on `parent` ahead of the commit that applies a +/// subsurface move, unless one is already outstanding. +fn arm_parent_frame( data: &mut AppData, parent: SubsurfaceParent, wl: &WlSurface ) +{ + let ss = match parent + { + SubsurfaceParent::Main => &mut data.main, + SubsurfaceParent::Overlay( id ) => match data.overlays.get_mut( &id ) { Some( s ) => s, None => return }, + }; + ss.frame_tick_due = false; + if ss.frame_pending { return; } + let _ = wl.frame( &data.qh, focus_of( parent ) ); + ss.frame_pending = true; +} + +/// Keep the frame cadence on a parent whose callback fired while the app +/// animates by subsurface motion only, when this pass moved nothing on it. +fn keep_parent_cadence( data: &mut AppData, parent: SubsurfaceParent, animating: bool ) +{ + let ss = match parent + { + SubsurfaceParent::Main => &mut data.main, + SubsurfaceParent::Overlay( id ) => match data.overlays.get_mut( &id ) { Some( s ) => s, None => return }, + }; + if !std::mem::take( &mut ss.frame_tick_due ) { return; } + if ss.frame_pending || !animating { return; } + let Some( wl ) = ss.surface.try_wl_surface().cloned() else { return }; + let _ = wl.frame( &data.qh, focus_of( parent ) ); + wl.commit(); + ss.frame_pending = true; +} + /// Resolve a subsurface's parent surface to `( wl_surface, phys_w, phys_h, /// scale )`. Returns `None` when an [`SubsurfaceParent::Overlay`] target is /// absent, not yet configured, or zero-sized — the caller skips the spec for @@ -116,6 +180,7 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) // Parents touched this frame; each committed once at the end so the // placement / mapping actually lands. let mut dirty_parents: Vec<( SubsurfaceParent, WlSurface )> = Vec::new(); + let mut committed: Vec = Vec::new(); // Drop subsurfaces whose id disappeared from the spec list. let present: std::collections::HashSet = @@ -129,7 +194,7 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) if let Some( slot ) = data.subsurfaces.remove( &id ) { let parent = slot.parent; - slot.destroy(); + slot.destroy( data.egl_context.as_ref() ); if let Some( ( wl, _, _, _ ) ) = resolve_parent( data, parent ) { mark_parent_dirty( &mut dirty_parents, parent, &wl ); @@ -164,9 +229,12 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) last_version: u64::MAX, rastered: false, parent: spec.parent, + scroll_canvases: HashMap::new(), + viewport_canvases: HashMap::new(), } ); mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl ); } + let parent_pending = parent_frame_pending( data, spec.parent ); // Tracked on the slot (not the canvas) because the GLES canvas lives // in `data.subsurface_gles_canvas`, shared across all subsurfaces. @@ -192,11 +260,15 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) slot.rastered = true; slot.last_version = spec.content_version; slot.last_size = ( pw, ph ); + committed.push( spec.id ); mark_parent_dirty( &mut dirty_parents, spec.parent, &parent_wl ); } + // A move waits for the parent's outstanding frame callback, so a drag + // commits once per vblank with the latest position instead of once + // per input sample. let slot = data.subsurfaces.get_mut( &spec.id ).unwrap(); - if slot.last_pos != ( spec.x, spec.y ) + if slot.last_pos != ( spec.x, spec.y ) && !parent_pending { let ( lx, ly ) = to_logical( spec.x, spec.y, scale ); slot.subsurface.set_position( lx, ly ); @@ -211,10 +283,24 @@ pub( crate ) fn reconcile_subsurfaces( data: &mut AppData ) // Applies pending subsurface placement / mapping without re-attaching the // parent buffer — the cheap per-frame move. - for ( _, wl ) in dirty_parents + for ( parent, wl ) in dirty_parents { + arm_parent_frame( data, parent, &wl ); wl.commit(); } + + let animating = data.app.is_animating(); + keep_parent_cadence( data, SubsurfaceParent::Main, animating ); + let overlay_ids: Vec = data.overlays.keys().copied().collect(); + for id in overlay_ids + { + keep_parent_cadence( data, SubsurfaceParent::Overlay( id ), animating ); + } + + for id in committed + { + data.app.on_subsurface_committed( id ); + } } /// Input-transparent subsurfaces never carry focus / hover / scroll state, @@ -233,6 +319,8 @@ fn empty_draw_ctx() -> DrawCtx scroll_offsets: HashMap::new(), scroll_rects: Vec::new(), scroll_canvases: HashMap::new(), + viewport_canvases: HashMap::new(), + viewport_seq: 0, scroll_navigable_items: HashMap::new(), scroll_clipped: Vec::new(), previous_widget_rects: Vec::new(), @@ -241,6 +329,14 @@ fn empty_draw_ctx() -> DrawCtx } } +fn slot_draw_ctx( scroll: &mut HashMap, viewport: &mut HashMap ) -> DrawCtx +{ + let mut ctx = empty_draw_ctx::(); + ctx.scroll_canvases = std::mem::take( scroll ); + ctx.viewport_canvases = std::mem::take( viewport ); + ctx +} + /// Raster a subsurface's content. Uses the GLES path when an [`EglContext`] /// is available — only there does the `surface-panel` backdrop blur render; /// the software fallback paints the same widgets without the blur. @@ -320,9 +416,11 @@ fn render_slot_gpu( canvas.clear(); let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 }; - let mut ctx = empty_draw_ctx::(); + let mut ctx = slot_draw_ctx::( &mut slot.scroll_canvases, &mut slot.viewport_canvases ); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); canvas.present(); + slot.scroll_canvases = ctx.scroll_canvases; + slot.viewport_canvases = ctx.viewport_canvases; let wl = &slot.surface; wl.set_buffer_scale( scale as i32 ); @@ -382,8 +480,10 @@ fn render_slot_software( canvas.clear(); let screen_rect = Rect { x: 0.0, y: 0.0, width: pw as f32, height: ph as f32 }; - let mut ctx = empty_draw_ctx::(); + let mut ctx = slot_draw_ctx::( &mut slot.scroll_canvases, &mut slot.viewport_canvases ); layout_and_draw::( view, canvas, screen_rect, &mut ctx, 0 ); + slot.scroll_canvases = ctx.scroll_canvases; + slot.viewport_canvases = ctx.viewport_canvases; canvas.write_to_wayland_buf( canvas_buf, swap_rb ); diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index 06b74ef..79d1299 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -171,6 +171,7 @@ pub( crate ) struct FrameState pub scroll_offsets: HashMap, pub scroll_rects: Vec<( Rect, usize, crate::widget::scroll::ScrollAxis )>, pub scroll_canvases: HashMap, + pub viewport_canvases: HashMap, /// Per-scroll list of `(flat_idx, content_y, height)` entries for every /// interactive item the scroll's child laid out, in document order. Includes /// items currently scrolled off-screen so keyboard arrow handlers can step @@ -199,6 +200,7 @@ impl FrameState scroll_offsets: HashMap::new(), scroll_rects: Vec::new(), scroll_canvases: HashMap::new(), + viewport_canvases: HashMap::new(), scroll_navigable_items: HashMap::new(), scroll_clipped: Vec::new(), prev_focused: None, @@ -298,6 +300,12 @@ pub( crate ) struct SurfaceState /// so the per-surface pacing follows the compositor (and the display /// refresh / VRR / "screen is off") instead of a fixed-period timer. pub frame_pending: bool, + /// Set by the frame callback while the app animates by moving + /// subsurfaces only; the subsurface reconcile keeps the cadence + /// with a bare frame request + commit when nothing moved. + pub frame_tick_due: bool, + /// Opaque rects (physical) last applied to the surface. + pub opaque_full: Option>, } impl SurfaceState @@ -335,6 +343,8 @@ impl SurfaceState last_popup_anchor: None, popup_reposition_token: 0, frame_pending: false, + frame_tick_due: false, + opaque_full: None, } } diff --git a/src/gles_render/clip.rs b/src/gles_render/clip.rs index 8007a21..ec37da9 100644 --- a/src/gles_render/clip.rs +++ b/src/gles_render/clip.rs @@ -144,7 +144,7 @@ impl GlesCanvas /// Composite the clip layer back onto the canvas FBO, multiplied by the /// anti-aliased coverage mask, then end the path clip. No-op when no path /// clip is open. - fn flush_clip_layer( &mut self ) + pub( super ) fn flush_clip_layer( &mut self ) { if !self.clip_layer_active { return; } self.clip_layer_active = false; diff --git a/src/gles_render/image.rs b/src/gles_render/image.rs index 7b151e1..86f0690 100644 --- a/src/gles_render/image.rs +++ b/src/gles_render/image.rs @@ -20,9 +20,13 @@ //! memory (RGBA8: `w × h × 4` per texture) with least-recently-drawn //! eviction, so a stream of distinct buffers (a photo carousel, video //! thumbnails) recycles textures instead of growing GPU memory for the -//! canvas' lifetime. The most recent entry is never evicted, so a +//! context's lifetime. The most recent entry is never evicted, so a //! single texture larger than the whole budget still draws — the cache //! then holds that one entry until something replaces it. +//! +//! One cache serves every canvas and sub-canvas on the same GL context +//! (`shared_image_cache`), so a surface that is torn down and recreated +//! draws its images without uploading them again. use glow::HasContext; @@ -38,6 +42,112 @@ use super::GlesCanvas; /// the compositor. pub const IMAGE_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024; +type ImageKey = ( u32, u32, u64 ); + +/// Content-keyed texture cache shared by every canvas on one GL context. +pub( super ) struct ImageCache +{ + map: std::collections::HashMap, + /// LRU order of keys, least-recent at the front. + lru: std::collections::VecDeque, + /// Estimated GPU bytes held (RGBA8: w × h × 4). + bytes: usize, + /// Pre-rendered themed surfaces (shadows + fill + insets), keyed by + /// size and style; see `surface_cache.rs`. + surfaces: std::collections::HashMap, + surfaces_lru: std::collections::VecDeque, + surfaces_bytes: usize, +} + +/// Byte budget for the pre-rendered surface textures. +pub const SURFACE_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024; + +impl ImageCache +{ + fn new() -> Self + { + Self + { + map: std::collections::HashMap::new(), + lru: std::collections::VecDeque::new(), + bytes: 0, + surfaces: std::collections::HashMap::new(), + surfaces_lru: std::collections::VecDeque::new(), + surfaces_bytes: 0, + } + } + + pub( super ) fn surface( &mut self, key: u64 ) -> Option<( glow::Texture, u32, u32 )> + { + let hit = *self.surfaces.get( &key )?; + if let Some( pos ) = self.surfaces_lru.iter().position( |k| *k == key ) + { + self.surfaces_lru.remove( pos ); + self.surfaces_lru.push_back( key ); + } + Some( hit ) + } + + pub( super ) fn insert_surface( &mut self, gl: &glow::Context, key: u64, tex: glow::Texture, w: u32, h: u32 ) + { + self.surfaces.insert( key, ( tex, w, h ) ); + self.surfaces_bytes += ( w as usize ) * ( h as usize ) * 4; + self.surfaces_lru.push_back( key ); + while self.surfaces_bytes > SURFACE_CACHE_MAX_BYTES && self.surfaces_lru.len() > 1 + { + let Some( old ) = self.surfaces_lru.pop_front() else { break }; + if let Some( ( t, ow, oh ) ) = self.surfaces.remove( &old ) + { + self.surfaces_bytes = self.surfaces_bytes.saturating_sub( ( ow as usize ) * ( oh as usize ) * 4 ); + // SAFETY: `t` was created through `gl` in `end_offscreen` and + // just left the map, so it is deleted exactly once. + unsafe { gl.delete_texture( t ); } + } + } + } + + /// Delete least-recently-drawn textures until the estimated byte total + /// fits [`IMAGE_CACHE_MAX_BYTES`]. The back of the LRU — the entry the + /// current draw is about to use — is never removed. + fn evict_overflow( &mut self, gl: &glow::Context ) + { + while self.bytes > IMAGE_CACHE_MAX_BYTES && self.lru.len() > 1 + { + let Some( key ) = self.lru.pop_front() else { break }; + if let Some( ( tex, w, h ) ) = self.map.remove( &key ) + { + self.bytes = self.bytes.saturating_sub( ( w as usize ) * ( h as usize ) * 4 ); + // SAFETY: `tex` was created through `gl` in `draw_image_data` + // and just left the map, so it is deleted exactly once. + unsafe { gl.delete_texture( tex ); } + } + } + } +} + +thread_local! +{ + static SHARED_IMAGE_CACHES: std::cell::RefCell> )>> = + std::cell::RefCell::new( Vec::new() ); +} + +/// The cache for the context behind `gl`, created on first use. +pub( super ) fn shared_image_cache( gl: &std::sync::Arc ) -> std::rc::Rc> +{ + let key = std::sync::Arc::as_ptr( gl ) as usize; + SHARED_IMAGE_CACHES.with( |caches| + { + let mut caches = caches.borrow_mut(); + if let Some( ( _, c ) ) = caches.iter().find( |( k, _ )| *k == key ) + { + return std::rc::Rc::clone( c ); + } + let c = std::rc::Rc::new( std::cell::RefCell::new( ImageCache::new() ) ); + caches.push( ( key, std::rc::Rc::clone( &c ) ) ); + c + } ) +} + impl GlesCanvas { /// Blit RGBA image data scaled to dest rect with opacity. @@ -62,17 +172,19 @@ impl GlesCanvas // depth). let cache_key = ( img_w, img_h, fingerprint_rgba( rgba_data ) ); - if !self.image_cache.contains_key( &cache_key ) + let cache = std::rc::Rc::clone( &self.image_cache ); + let mut cache = cache.borrow_mut(); + if !cache.map.contains_key( &cache_key ) { let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 ); - self.image_cache.insert( cache_key, ( tex, img_w, img_h ) ); - self.image_cache_bytes += ( img_w as usize ) * ( img_h as usize ) * 4; - self.image_cache_lru.push_back( cache_key ); - self.evict_image_cache_overflow(); - } else if let Some( pos ) = self.image_cache_lru.iter().position( |k| *k == cache_key ) + cache.map.insert( cache_key, ( tex, img_w, img_h ) ); + cache.bytes += ( img_w as usize ) * ( img_h as usize ) * 4; + cache.lru.push_back( cache_key ); + cache.evict_overflow( &self.gl ); + } else if let Some( pos ) = cache.lru.iter().position( |k| *k == cache_key ) { - self.image_cache_lru.remove( pos ); - self.image_cache_lru.push_back( cache_key ); + cache.lru.remove( pos ); + cache.lru.push_back( cache_key ); } // Snap to integer pixels. With GL_LINEAR sampling, a @@ -90,12 +202,12 @@ impl GlesCanvas height: dest.height.round(), }; - if let Some( ( tex, _, _ ) ) = self.image_cache.get( &cache_key ) + if let Some( ( tex, _, _ ) ) = cache.map.get( &cache_key ) { let mvp = ortho_rect( self.width, self.height, dest ); let alpha = opacity * self.global_alpha; // SAFETY: see `primitives.rs` module doc. `*tex` is owned by - // `self.image_cache` so it outlives the call. Eviction ran + // the shared cache so it outlives the call. Eviction ran // before this borrow and never removes the most-recent key, // which is `cache_key`. unsafe @@ -114,27 +226,6 @@ impl GlesCanvas } } - /// Delete least-recently-drawn cached textures until the estimated - /// byte total fits [`IMAGE_CACHE_MAX_BYTES`]. The back of the LRU — - /// the entry the current draw is about to use — is never removed. - fn evict_image_cache_overflow( &mut self ) - { - while self.image_cache_bytes > IMAGE_CACHE_MAX_BYTES && self.image_cache_lru.len() > 1 - { - let Some( key ) = self.image_cache_lru.pop_front() else { break }; - if let Some( ( tex, w, h ) ) = self.image_cache.remove( &key ) - { - self.image_cache_bytes = self.image_cache_bytes - .saturating_sub( ( w as usize ) * ( h as usize ) * 4 ); - // SAFETY: `tex` was created through `self.gl` in the insert - // path above and just left the map, so it is deleted exactly - // once. Deleting a bound texture is defined in GLES (the - // binding reverts to 0); no draw is in flight here. - unsafe { self.gl.delete_texture( tex ); } - } - } - } - /// Draw an externally-owned GL texture into `dest`. /// /// The caller owns the texture and is responsible for keeping it valid diff --git a/src/gles_render/mod.rs b/src/gles_render/mod.rs index dd5fe3d..a105eb0 100644 --- a/src/gles_render/mod.rs +++ b/src/gles_render/mod.rs @@ -45,7 +45,7 @@ //! method — see the module's own doc for when to use the guards //! and when not to. -use std::collections::{ HashMap, VecDeque }; +use std::collections::HashMap; use std::sync::Arc; use fontdue::Font; @@ -62,6 +62,7 @@ pub( crate ) mod clip; pub( crate ) mod primitives; pub( crate ) mod text; pub( crate ) mod image; +pub( crate ) mod surface_cache; mod raii; // ─── Public types ──────────────────────────────────────────────────────────── @@ -356,11 +357,9 @@ pub struct GlesCanvas // `DefaultHasher` pass over the bytes per draw call — fast for // any reasonable icon size. Bounded by `image.rs`'s // `IMAGE_CACHE_MAX_BYTES` with LRU eviction. - image_cache: HashMap<(u32, u32, u64), (glow::Texture, u32, u32)>, - // LRU order of `image_cache` keys, least-recent at the front. - image_cache_lru: VecDeque<(u32, u32, u64)>, - // Estimated GPU bytes held by `image_cache` (RGBA8: w × h × 4). - image_cache_bytes: usize, + // Shared by every canvas and sub-canvas on the same GL context, so an + // overlay or subsurface that is recreated finds its textures resident. + image_cache: std::rc::Rc>, // Gradient LUT cache: FNV-ish hash of the 512×RGBA8 LUT bytes → texture. // Gradients are theme-derived and constant across frames; caching avoids @@ -454,10 +453,6 @@ impl Drop for GlesCanvas } self.glyph_cache.clear(); self.gl.delete_texture( self.atlas_texture ); - for ( _, ( tex, _, _ ) ) in self.image_cache.drain() - { - self.gl.delete_texture( tex ); - } for ( _, tex ) in self.gradient_lut_cache.drain() { self.gl.delete_texture( tex ); diff --git a/src/gles_render/primitives.rs b/src/gles_render/primitives.rs index bdd884b..286d4ab 100644 --- a/src/gles_render/primitives.rs +++ b/src/gles_render/primitives.rs @@ -546,8 +546,8 @@ impl GlesCanvas } } - /// Draw a line as a thin axis-aligned rect (diagonal lines fall back to - /// stamping small squares). + /// Draw a line as a thin axis-aligned rect, or as one rotated quad with + /// round caps when it is diagonal. pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 ) { let dx = x1 - x0; @@ -562,14 +562,59 @@ impl GlesCanvas } else if dx.abs() < 0.1 { self.fill_rect( Rect { x: min_x - width / 2.0, y: min_y, width, height: dy.abs() }, color, Corners::ZERO ); } else { - let steps = len.ceil() as usize; - for i in 0..steps - { - let t = i as f32 / len; - let px = x0 + dx * t; - let py = y0 + dy * t; - self.fill_rect( Rect { x: px - width / 2.0, y: py - width / 2.0, width, height: width }, color, Corners::ZERO ); - } + self.fill_rotated_line( x0, y0, dx, dy, len, color, width ); + } + } + + /// One quad of `len × width` rotated along `(dx, dy)` from `(x0, y0)`, + /// filled through the rect shader so the caps and edges stay + /// antialiased. The MVP maps the unit quad straight onto the rotated + /// rect, padded by 1 px like `fill_rect` for the SDF band. + fn fill_rotated_line( &mut self, x0: f32, y0: f32, dx: f32, dy: f32, len: f32, color: Color, width: f32 ) + { + let half = width / 2.0 + 1.0; + let bbox = Rect + { + x: x0.min( x0 + dx ) - half, + y: y0.min( y0 + dy ) - half, + width: dx.abs() + 2.0 * half, + height: dy.abs() + 2.0 * half, + }; + if self.rect_culled( bbox, 1.0 ) { return; } + self.activate_target(); + + let pad = 1.0_f32; + let cos = dx / len; + let sin = dy / len; + let len_p = len + 2.0 * pad; + let wid_p = width + 2.0 * pad; + // Unit-quad origin in line-local space, then rotated into screen space. + let lx = -pad; + let ly = -pad - width / 2.0; + let sx0 = x0 + lx * cos - ly * sin; + let sy0 = y0 + lx * sin + ly * cos; + let w = self.width as f32; + let h = self.height as f32; + let mvp = [ + 2.0 * len_p * cos / w, -2.0 * len_p * sin / h, 0.0, 0.0, + -2.0 * wid_p * sin / w, -2.0 * wid_p * cos / h, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 2.0 * sx0 / w - 1.0, 1.0 - 2.0 * sy0 / h, 0.0, 1.0, + ]; + let alpha = color.a * self.global_alpha; + // SAFETY: see module doc; same uniform set as `fill_rect`. + unsafe + { + self.gl.use_program( Some( self.rect_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp ); + self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha ); + self.gl.uniform_2_f32( Some( &self.u_rect_size ), len, width ); + self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &Corners::all( width / 2.0 ).to_uniform() ); + self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), 0.0 ); + self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); } } diff --git a/src/gles_render/setup.rs b/src/gles_render/setup.rs index 5d08cb6..e2d534f 100644 --- a/src/gles_render/setup.rs +++ b/src/gles_render/setup.rs @@ -327,6 +327,7 @@ impl GlesCanvas tex }; + let image_cache = super::image::shared_image_cache( &gl ); Self { gl, @@ -459,9 +460,7 @@ impl GlesCanvas atlas_cursor_y: 0, atlas_row_height: 0, glyph_cache: HashMap::new(), - image_cache: HashMap::new(), - image_cache_lru: std::collections::VecDeque::new(), - image_cache_bytes: 0, + image_cache, gradient_lut_cache: HashMap::new(), clip_scissor: None, clip_layer: None, @@ -675,9 +674,7 @@ impl GlesCanvas atlas_cursor_y: 0, atlas_row_height: 0, glyph_cache: HashMap::new(), - image_cache: HashMap::new(), - image_cache_lru: std::collections::VecDeque::new(), - image_cache_bytes: 0, + image_cache: std::rc::Rc::clone( &self.image_cache ), gradient_lut_cache: HashMap::new(), clip_scissor: None, clip_layer: None, diff --git a/src/gles_render/surface_cache.rs b/src/gles_render/surface_cache.rs new file mode 100644 index 0000000..3804796 --- /dev/null +++ b/src/gles_render/surface_cache.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Pre-rendered themed surfaces. A card drawn with a Gaussian drop shadow, +//! a gradient fill and several inset shadows costs half a dozen full-quad +//! shader passes; nothing in that chrome changes between frames, so it is +//! rendered once into a texture per (size, style) and blitted afterwards. +//! The textures live in the per-context [`super::image::ImageCache`]. + +use glow::HasContext; + +use crate::types::Rect; + +use super::GlesCanvas; +use super::helpers::{ alloc_fbo_tex, ortho_rect }; + +/// Canvas state saved while drawing into an offscreen texture. +pub( crate ) struct OffscreenTarget +{ + fbo: glow::Framebuffer, + saved_fbo: glow::Framebuffer, + saved_tex: glow::Texture, + saved_size: ( u32, u32 ), + saved_clip: Option, + saved_alpha: f32, + saved_layer: bool, +} + +impl GlesCanvas +{ + pub( crate ) fn cached_surface( &self, key: u64 ) -> Option<( glow::Texture, u32, u32 )> + { + self.image_cache.borrow_mut().surface( key ) + } + + pub( crate ) fn insert_cached_surface( &self, key: u64, tex: glow::Texture, w: u32, h: u32 ) + { + self.image_cache.borrow_mut().insert_surface( &self.gl, key, tex, w, h ); + } + + /// Redirect every draw to a fresh transparent `w × h` texture until + /// [`Self::end_offscreen`]. Clip, opacity and path-clip layer are + /// suspended meanwhile and restored afterwards. + pub( crate ) fn begin_offscreen( &mut self, w: u32, h: u32 ) -> OffscreenTarget + { + self.flush_clip_layer(); + // SAFETY: the context is current (canvas invariant); the FBO and + // texture are created through `self.gl` and released in + // `end_offscreen` / by the cache eviction. + let ( fbo, tex ) = unsafe + { + let fbo = self.gl.create_framebuffer().expect( "create_framebuffer" ); + let tex = alloc_fbo_tex( &self.gl, self.version, w, h ); + self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) ); + self.gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0, glow::TEXTURE_2D, Some( tex ), 0 ); + ( fbo, tex ) + }; + let target = OffscreenTarget + { + fbo, + saved_fbo: self.fbo, + saved_tex: self.fbo_tex, + saved_size: ( self.width, self.height ), + saved_clip: self.clip_scissor, + saved_alpha: self.global_alpha, + saved_layer: self.clip_layer_active, + }; + self.fbo = fbo; + self.fbo_tex = tex; + self.width = w; + self.height = h; + self.clip_scissor = None; + self.global_alpha = 1.0; + self.clip_layer_active = false; + self.clear(); + target + } + + /// Restore the canvas target and hand back the offscreen texture. + pub( crate ) fn end_offscreen( &mut self, target: OffscreenTarget ) -> glow::Texture + { + let tex = self.fbo_tex; + self.fbo = target.saved_fbo; + self.fbo_tex = target.saved_tex; + self.width = target.saved_size.0; + self.height = target.saved_size.1; + self.clip_scissor = target.saved_clip; + self.global_alpha = target.saved_alpha; + self.clip_layer_active = target.saved_layer; + // SAFETY: `target.fbo` was created in `begin_offscreen` and is no + // longer bound after `activate_target` below. + unsafe { self.gl.delete_framebuffer( target.fbo ); } + self.activate_target(); + tex + } + + /// Composite a premultiplied offscreen texture at `( x, y )` with the + /// canvas opacity, through the same path a sub-canvas blit takes. + pub( crate ) fn blit_texture( &mut self, tex: glow::Texture, w: u32, h: u32, x: f32, y: f32 ) + { + let dest = Rect { x, y, width: w as f32, height: h as f32 }; + if let Some( clip ) = self.clip_scissor + { + let outside = dest.x >= clip.x + clip.width || dest.x + dest.width <= clip.x + || dest.y >= clip.y + clip.height || dest.y + dest.height <= clip.y; + if outside { return; } + } + self.activate_target(); + let mvp = ortho_rect( self.width, self.height, dest ); + let alpha = self.global_alpha; + // SAFETY: `tex` belongs to the shared cache of this context and + // outlives the call; the blend state is the premultiplied over + // composite the sub-blit shader expects. + unsafe + { + self.gl.use_program( Some( self.sub_blit_program ) ); + self.gl.uniform_matrix_4_f32_slice( Some( &self.u_subblit_mvp ), false, &mvp ); + self.gl.uniform_1_f32( Some( &self.u_subblit_opacity ), alpha ); + self.gl.uniform_1_f32( Some( &self.u_subblit_fade_bottom ), 0.0 ); + self.gl.uniform_1_f32( Some( &self.u_subblit_height_px ), h as f32 ); + self.gl.active_texture( glow::TEXTURE0 ); + self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); + self.gl.uniform_1_i32( Some( &self.u_subblit_sampler ), 0 ); + self.gl.bind_vertex_array( Some( self.quad_vao ) ); + self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); + self.gl.bind_vertex_array( None ); + self.gl.bind_texture( glow::TEXTURE_2D, None ); + } + } +} diff --git a/src/input/dispatch/outcomes.rs b/src/input/dispatch/outcomes.rs index d35dfae..fe872a3 100644 --- a/src/input/dispatch/outcomes.rs +++ b/src/input/dispatch/outcomes.rs @@ -109,6 +109,25 @@ impl AppData } } + /// Give a settle the app runs internally a first frame to latch onto. + /// When only subsurfaces move, a bare frame request keeps the main + /// buffer as it is and the subsurface reconcile carries the motion. + fn kick_animation( &mut self ) + { + if self.app.subsurface_motion_only() + { + if self.main.frame_pending { return; } + let Some( wl ) = self.main.surface.try_wl_surface().cloned() else { return }; + let _ = wl.frame( &self.qh, SurfaceFocus::Main ); + wl.commit(); + self.main.frame_pending = true; + return; + } + self.dirty_caches(); + self.main.request_redraw(); + self.main.frame_pending = false; + } + fn apply_release_event ( &mut self, @@ -145,9 +164,7 @@ impl AppData // Message, so the update-driven redraw never fires. // Kick the main surface here so `is_animating()` has a // frame to latch onto. - self.dirty_caches(); - self.main.request_redraw(); - self.main.frame_pending = false; + self.kick_animation(); } ReleaseEvent::SwipeRight => { @@ -155,9 +172,7 @@ impl AppData { self.pending_msgs.push( msg ); } - self.dirty_caches(); - self.main.request_redraw(); - self.main.frame_pending = false; + self.kick_animation(); } ReleaseEvent::SwipeUp => { @@ -165,9 +180,7 @@ impl AppData { self.pending_msgs.push( msg ); } - self.dirty_caches(); - self.main.request_redraw(); - self.main.frame_pending = false; + self.kick_animation(); for ss in self.overlays.values_mut() { ss.request_redraw(); @@ -180,9 +193,7 @@ impl AppData { self.pending_msgs.push( msg ); } - self.dirty_caches(); - self.main.request_redraw(); - self.main.frame_pending = false; + self.kick_animation(); for ss in self.overlays.values_mut() { ss.request_redraw(); @@ -195,9 +206,7 @@ impl AppData { self.pending_msgs.push( msg ); } - self.dirty_caches(); - self.main.request_redraw(); - self.main.frame_pending = false; + self.kick_animation(); for ss in self.overlays.values_mut() { ss.request_redraw(); diff --git a/src/render/mod.rs b/src/render/mod.rs index 6c28d46..945f0ea 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -312,6 +312,17 @@ impl Canvas } } + /// Replace the layout viewport for a subtree drawn on this canvas and + /// return the previous one, so the caller can restore it. + pub( crate ) fn layout_viewport_override( &mut self, v: Option<( f32, f32 )> ) -> Option<( f32, f32 )> + { + match self + { + Canvas::Software( c ) => std::mem::replace( &mut c.layout_viewport, v ), + Canvas::Gles( c ) => std::mem::replace( &mut c.layout_viewport, v ), + } + } + /// Resolve an explicit [`Length`] in **geometry** space: against /// [`Self::viewport_layout`], with this canvas' [`Self::density`]. /// Widgets resolve caller-supplied geometry lengths through this so @@ -785,6 +796,17 @@ impl Canvas { let corners = corners.into(); + let cacheable = matches!( self, Canvas::Gles( _ ) ) + && ( !outer_shadows.is_empty() || !inset_shadows.is_empty() ) + && outer_shadows.iter().all( |s| s.blend == crate::theme::BlendMode::Normal ) + && inset_shadows.iter().all( |s| s.blend == crate::theme::BlendMode::Normal ) + && rect.width >= 1.0 && rect.height >= 1.0; + if cacheable + { + self.fill_surface_cached( rect, fill, outer_shadows, inset_shadows, corners ); + return; + } + for shadow in outer_shadows { self.fill_shadow_outer( rect, shadow, corners ); @@ -798,6 +820,76 @@ impl Canvas } } + /// GLES: render the chrome once per (size, style) into a texture held by + /// the context's cache and blit it, instead of running the shadow and + /// inset shaders over the surface every frame. + fn fill_surface_cached + ( + &mut self, + rect: Rect, + fill: &ThemePaint, + outer_shadows: &[Shadow], + inset_shadows: &[InsetShadow], + corners: Corners, + ) + { + use std::hash::{ Hash, Hasher }; + + let margin = outer_shadows.iter() + .map( |s| s.blur.max( 0.0 ) + s.spread.max( 0.0 ) + s.offset[ 0 ].abs().max( s.offset[ 1 ].abs() ) + 1.0 ) + .fold( 1.0_f32, f32::max ) + .ceil(); + let w = rect.width; + let h = rect.height; + let tw = ( w.ceil() + 2.0 * margin ) as u32; + let th = ( h.ceil() + 2.0 * margin ) as u32; + + let mut hasher = std::hash::DefaultHasher::new(); + ( ( w * 4.0 ).round() as u32, ( h * 4.0 ).round() as u32 ).hash( &mut hasher ); + for r in [ corners.tl, corners.tr, corners.br, corners.bl ] { r.to_bits().hash( &mut hasher ); } + self.dpi_scale().to_bits().hash( &mut hasher ); + format!( "{fill:?}|{outer_shadows:?}|{inset_shadows:?}" ).hash( &mut hasher ); + let key = hasher.finish(); + + let cached = match self { Canvas::Gles( c ) => c.cached_surface( key ), _ => None }; + let tex = match cached + { + Some( ( tex, _, _ ) ) => tex, + None => + { + let target = match self + { + Canvas::Gles( c ) => c.begin_offscreen( tw, th ), + _ => return, + }; + let local = Rect { x: margin, y: margin, width: w, height: h }; + for shadow in outer_shadows + { + self.fill_shadow_outer( local, shadow, corners ); + } + self.fill_paint_rect( local, fill, corners ); + for inset in inset_shadows + { + self.fill_shadow_inset( local, inset, corners ); + } + match self + { + Canvas::Gles( c ) => + { + let tex = c.end_offscreen( target ); + c.insert_cached_surface( key, tex, tw, th ); + tex + } + _ => return, + } + } + }; + if let Canvas::Gles( c ) = self + { + c.blit_texture( tex, tw, th, ( rect.x - margin ).round(), ( rect.y - margin ).round() ); + } + } + pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 ) { match self diff --git a/src/wallpaper.rs b/src/wallpaper.rs index b2ea373..a7fae01 100644 --- a/src/wallpaper.rs +++ b/src/wallpaper.rs @@ -112,7 +112,7 @@ impl WallpaperBundle /// callers can use this before the surface has been sized. pub fn for_size( &self, sw: u32, sh: u32 ) -> ImageData { - if sw == 0 || sh == 0 || sw >= sh + if sw == 0 || sh == 0 { return self.landscape(); } @@ -127,13 +127,30 @@ impl WallpaperBundle } } - let cropped = crop_left_to_aspect( &self.landscape, sw, sh ); + let base = if sw >= sh { self.landscape() } else { crop_left_to_aspect( &self.landscape, sw, sh ) }; + let cropped = fit_to_surface( base, sw, sh ); let result = ( Arc::clone( &cropped.0 ), cropped.1, cropped.2 ); *guard = Some( ( key, cropped ) ); result } } +/// Downscale to the surface size when the source is larger: a 4K crop drawn +/// on a 720×1440 panel would otherwise be uploaded at 10 MB per texture. +fn fit_to_surface( src: ImageData, sw: u32, sh: u32 ) -> ImageData +{ + let ( ref rgba, w, h ) = src; + if w <= sw && h <= sh { return src; } + let scale = ( sw as f32 / w as f32 ).max( sh as f32 / h as f32 ); + let nw = ( ( w as f32 * scale ).ceil() as u32 ).max( 1 ); + let nh = ( ( h as f32 * scale ).ceil() as u32 ).max( 1 ); + let Some( view ) = image::ImageBuffer::, &[u8]>::from_raw( w, h, rgba.as_slice() ) else { return src }; + // Bilinear: the crop is at most a few times the surface size and this + // runs on the UI thread of a phone at the first use of each size. + let resized = image::imageops::resize( &view, nw, nh, image::imageops::FilterType::Triangle ); + ( Arc::new( resized.into_raw() ), nw, nh ) +} + fn decode_bytes( bytes: &[u8] ) -> Result { use image::GenericImageView as _; @@ -245,6 +262,16 @@ mod tests assert_eq!( ( w, h ), ( 1, 10 ) ); } + #[ test ] + fn oversized_source_is_downscaled_to_surface() + { + let bundle = WallpaperBundle::from_decoded( xgrad( 64, 64 ) ); + let ( _, w, h ) = bundle.for_size( 16, 16 ); + assert_eq!( ( w, h ), ( 16, 16 ) ); + let ( _, w, h ) = bundle.for_size( 8, 16 ); + assert_eq!( ( w, h ), ( 8, 16 ) ); + } + #[ test ] fn zero_size_returns_landscape() { diff --git a/tests/render_pixels.rs b/tests/render_pixels.rs index 13bef02..740ecf9 100644 --- a/tests/render_pixels.rs +++ b/tests/render_pixels.rs @@ -6,7 +6,7 @@ // canonical scenes that exercise the partial-redraw and full-redraw paths. use ltk::core::{ Canvas, RenderOptions, UiSurface }; -use ltk::{ button, column, container, spacer, text, Color, Element }; +use ltk::{ button, column, container, spacer, text, viewport, Color, Element }; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -97,6 +97,34 @@ fn background_fills_every_pixel_when_view_is_empty() } } +// ── viewport clipping ───────────────────────────────────────────────────────── + +// A viewport without a bottom fade clips on the parent canvas (scissor / +// mask) instead of drawing into a sub-canvas: the child must still be cut +// at the viewport's height, and nothing of it may leak below. +#[ test ] +fn viewport_clips_its_child_to_the_assigned_height() +{ + let mut surface = UiSurface::<()>::new( 32, 64 ); + let child: Element<()> = container::<()>( spacer().height( 48.0 ) ) + .background( Color::rgb( 1.0, 0.0, 0.0 ) ) + .into(); + let view: Element<()> = column::<()>() + .padding( 0.0 ) + .spacing( 0.0 ) + .push( viewport( child ).height( 16.0 ) ) + .into(); + let _ = surface.render( + &view, + RenderOptions::full_canvas( 32, 64 ).background( Color::rgb( 0.0, 0.0, 0.0 ) ), + ); + let buf = extract_pixels( &surface, 32, 64 ); + let red_at = |x: u32, y: u32| buf[ ( ( y * 32 + x ) * 4 ) as usize ]; + assert!( near( red_at( 16, 4 ), 255, 2 ), "the child paints inside the viewport" ); + assert!( near( red_at( 16, 24 ), 0, 2 ), "the child is clipped right below the viewport" ); + assert!( near( red_at( 16, 40 ), 0, 2 ), "nothing of the child leaks further down" ); +} + // ── determinism ─────────────────────────────────────────────────────────────── fn counter_view( count: u32 ) -> Element<()>