Files
ltk/src/gles_render/framebuffer.rs
T
admin 45b8ddd483
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
A print dialog with a preview, CUPS and the print portal, a Windows backend, and ltk::dirs for the user's folders
Printing. An app that wanted to print had nothing in ltk to ask the user where and how, nor to reach a printer. `ltk::print` (`src/print/`) is a dialog built as `FileChooser` is: the app holds a `PrintDialog`, puts its `view()` in its own and hands its `PrintDialogMsg`s to `update()`, which returns a `PrintEvent`; `wrap` makes the app's message of the dialog's, so the printer list reaches it through the app's `ChannelSender`. It offers the destination, the printers CUPS knows with their state and "Save as PDF", the number of copies, all pages or a range such as `1-3, 5, 8-`, the paper (A4, Letter, Legal, A3, A5), the orientation, the margins, the scale, two-sided printing, colour and background graphics, the copies, sides and colour only for a printer. `PrintOptions` holds what was chosen and survives between dialogs, `parse_pages` and `format_pages` read and write the ranges. On a desktop the dialog is a card with ltk's `dialog()`, the preview on the left and the settings on the right; `compact()` makes it a whole page for a phone. `.printer( name )` and `.save_as_pdf()` choose where it starts.
The preview. With `.preview( true )` the dialog asks the app for one with `PrintEvent::Preview( options )` whenever a setting that moves the pages changes, and shows what the app hands back to `PrintDialogMsg::previewed`, a `Preview` of some pages as RGBA with the document's total, one page at a time with buttons to move between them, greyed when colour is off. While one is being rendered a new request waits and is sent only if the layout changed meanwhile, so turning the scale quickly does not stack renders; until the first arrives the sheet's box shows "Preparing the preview…", kept to the sheet's width so it does not push the settings aside.
CUPS. `print/cups.rs` loads `libcups.so.2` with `dlopen` at first use, so ltk neither links against it nor needs its headers, and a system without CUPS only loses its printers. It uses the destinations API, which also lists the network's driverless printers that have no queue: `printers()` with the default first, `print( printer, title, options, pdf )` sending the PDF as one job with `copies`, `media` as a PWG name, `sides` and `print-color-mode`, `jobs()`, `cancel()` and `set_default()`, which writes the user's `lpoptions`. `ltk::print::send`, `list_printers`, `jobs`, `cancel_job` and `set_default` run them on a thread and answer through the app's sender; `ltk::print::blocking` exposes them as they are, for code that has its own thread, such as a portal backend. `print/none.rs` stands in on Windows, Android and the browser, where only saving as PDF is offered.
The portal. An app in a Flatpak cannot reach the CUPS socket. When `/.flatpak-info` exists, the dialog shows nothing of its own: it calls `org.freedesktop.portal.Print.PreparePrint` with the options as GTK's print settings and page setup, and the portal's own dialog answers; `Destination::Portal` then carries its token, and `send` passes the PDF to `Print` through a memfd. The Response signal is subscribed to on the request's path before the call, so a quick answer is not missed. `print/gtk.rs` translates `PrintOptions` to and from GTK's keys (`n-copies`, `page-ranges` counted from zero, `duplex`, `use-color`, `scale` in percent, the paper by its PWG name and size in millimetres), which a portal backend uses for the other direction. zbus 4, the one accesskit_unix already brings, joins the `wayland` feature for it.
The dialog's strings are in ltk's seven locales. `file_chooser::view::icon` is shared with the dialog.
Windows. The `windows` feature runs `ltk::run` in a Win32 window on Windows 10 1703 or later, with Windows's own frame, drawn through ANGLE's EGL and OpenGL ES when `libEGL.dll` and `libGLESv2.dll` sit beside the executable and in software through GDI otherwise (`src/platform/windows/`). It sizes the window by `App::window_size_hint` at the display's DPI and follows per-monitor DPI changes at fractional scales and the accessibility text size; it takes the mouse, the wheel, touch through `WM_POINTER`, and keys with Windows's repeat, dead keys and AltGr; it has the system clipboard, the cursor's shape, full screen, and moves and resizes begun from the app's own chrome, and keeps drawing while the window is moved or resized. Themes, fonts, keyboard layouts and MIME globs come from `ltk\` beside the executable, which `ltk::resource_dir` returns there by default; the font fallback chain and the monospace face are Windows's own, the system face stays the embedded Sora, and the file chooser offers each drive's root. A Win32 message loop cannot wait on calloop, so the runtime's timers and channels go through the queue the web backend already used: `build.rs` sets `cfg( queued_sources )` for both, and `AppData::dispatch_sources` moves from the web backend to `event_loop/sources.rs`. `Canvas::present_to` presents a GPU frame stretched over a window that a fractional scale left a pixel off it, and `EglContext::new_windows` and `create_native_surface` give ANGLE's display and an `HWND`'s surface. `App::save_state` is saved into `%LOCALAPPDATA%\<app_id>\` every 30 seconds, on exit and when the user's session ends, and restored as on Wayland: the part of the Wayland session runtime that does not speak the protocol, deciding why the app starts and handing it its bytes, is now `session_state::bootstrap`, which both backends call, with the file modes and the check for a running instance done each platform's way. `RunError::Window` reports a window Windows refused. `make windows-deps` and `make windows-check` typecheck the backend from Debian, compiling std from `rust-src`. `docs/windows.md` describes building, what ships beside the executable and what the backend does.
ltk::dirs. `home`, `data_home`, `config_home`, `cache_home` and `state_home`: XDG's on Linux, with their usual fallbacks under the home folder, and on Windows `USERPROFILE`, `APPDATA` for data and settings and `LOCALAPPDATA` for the cache and state, an `XDG_*` variable that is set winning everywhere. An app finds its files there instead of reading `HOME` and the XDG variables itself, which on Windows are unset; ltk's own lookups of themes, keyboard layouts, MIME globs, the file chooser's folders and the session state now go through it, and `state_dir` takes the base it is given, its tests with it.
Docs. The links to `WidgetId` in the crate's, `types`' and `App`'s documentation name the struct, since the `const fn` of the same name made them ambiguous to rustdoc. `README.md` names the other backends and `CHANGELOG.md` records the Windows backend and `ltk::dirs`.
Not done. The print dialog has no CHANGELOG entry nor a page in `docs/widgets.md`. Under the portal there is no preview, since the portal's dialog gets the document only at the end. The desktop card's settings column does not scroll, so with a list open in a low window it may not fit. Tests cover the page ranges and the translation to GTK's keys, not the dialog, CUPS or the portal. On Windows there are no overlays, secondary surfaces or subsurfaces, no preedit for the input method and no accessibility.
2026-11-01 22:49:18 +01:00

392 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! FBO / framebuffer management for [`GlesCanvas`]: sub-canvas blit,
//! main-FBO ⇄ default-framebuffer present, lazy auxiliary FBO for
//! snapshot-based effects (Overlay blend inset shadow), and the
//! externally-exposed borrowed-texture view.
//!
//! See `primitives.rs` module doc for the canvas-wide `unsafe` contract
//! shared by every block in this file. Per-block notes below only call
//! out what is specific to the operation.
use glow::HasContext;
use crate::types::{ CompositeMode, Rect };
use super::helpers::{ alloc_fbo_tex, ortho_rect };
#[ cfg( not( target_arch = "wasm32" ) ) ]
use super::helpers::{ native_framebuffer_id, native_texture_id };
use super::raii::{ FboBinding, ProgramBinding };
use super::{ BorrowedGlesTexture, GlesCanvas };
impl GlesCanvas
{
/// Composite `src`'s FBO into this canvas at top-left `( dest_x, dest_y )`,
/// premultiplied over. `src` must share this canvas's GL context (guaranteed
/// for sub-canvases). Equivalent to [`Self::blit_fade_bottom`] with no fade.
pub fn blit( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32 )
{
self.blit_fade_bottom( src, dest_x, dest_y, 0.0 );
}
/// Blit `src` into this canvas at `( dest_x, dest_y )`, optionally feathering
/// the last `fade_bottom_px` source rows so the bottom edge dissolves into
/// transparency instead of cutting off cleanly. Used by viewports whose
/// bottom edge is the leading edge of a slide-down animation, where a hard
/// cut against the underlying layer reads as a knife. With `fade_bottom_px
/// == 0.0` this matches [`Self::blit`] exactly.
pub fn blit_fade_bottom( &mut self, src: &GlesCanvas, dest_x: i32, dest_y: i32, fade_bottom_px: f32 )
{
self.activate_target();
let dest = Rect
{
x: dest_x as f32,
y: dest_y as f32,
width: src.width as f32,
height: src.height as f32,
};
let mvp = ortho_rect( self.width, self.height, dest );
let alpha = self.global_alpha;
let height_px = src.height as f32;
let fade_clamp = fade_bottom_px.max( 0.0 ).min( height_px );
// SAFETY: `src.fbo_tex` is owned by `src` (a `&GlesCanvas` argument)
// and outlives the call. `src` and `self` share the same `Arc<glow::Context>`
// — verified by construction (sub-canvases are built via `sub_canvas`,
// which clones `Arc::clone(&self.gl)`) — so sampling `src`'s texture
// from `self`'s FBO is well-defined.
unsafe
{
// Both the main canvas and the sub-canvas FBO hold premultiplied
// colour, and the global blend is `(ONE, ONE_MINUS_SRC_ALPHA)` —
// the premul over-composite formula this blit needs. No temporary
// blend switch necessary.
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 ), fade_clamp );
self.gl.uniform_1_f32( Some( &self.u_subblit_height_px ), height_px );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( src.fbo_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 );
}
}
/// Re-bind our FBO + viewport + scissor as the active GL state. Cheap and
/// idempotent, called at the top of every draw / clear / clip method so
/// that switching between canvases (e.g. main → sub-canvas → main) leaves
/// each one's state correct without explicit "make active" calls.
///
/// Why this exists: GL state (FBO binding, scissor box, viewport) is
/// global — there is no implicit per-canvas state. When rendering
/// switches between targets, every method on the active canvas must
/// reassert its own FBO + viewport, plus re-enable its own scissor (or
/// disable scissor when the canvas has no clip).
pub( super ) fn activate_target( &self )
{
// While a path clip is active, draws are redirected to the offscreen clip
// layer (unclipped); the anti-aliased mask is applied when the layer is
// composited back on clip end. Otherwise draws go to `fbo` with the
// active scissor.
// SAFETY: rebinds a canvas-owned FBO + viewport + scissor. All values
// (`self.fbo`, `self.clip_layer`, `self.width/height`, `self.clip_scissor`)
// live as long as `&self`, and the bind is idempotent.
let target = match ( self.clip_layer_active, self.clip_layer )
{
( true, Some( ( fbo, _ ) ) ) => fbo,
_ => self.fbo,
};
let to_layer = self.clip_layer_active && self.clip_layer.is_some();
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( target ) );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
match self.clip_scissor
{
Some( r ) if !to_layer =>
{
let ( x, y, w, h ) = self.scissor_pixels( r );
self.gl.enable( glow::SCISSOR_TEST );
self.gl.scissor( x, y, w, h );
}
_ =>
{
self.gl.disable( glow::SCISSOR_TEST );
}
}
}
self.apply_composite();
}
/// Set the global blend function to this canvas's [`CompositeMode`] over premultiplied colour.
pub( super ) fn apply_composite( &self )
{
let ( src, dst ) = match self.composite
{
CompositeMode::SrcOver => ( glow::ONE, glow::ONE_MINUS_SRC_ALPHA ),
CompositeMode::Clear => ( glow::ZERO, glow::ZERO ),
CompositeMode::Src => ( glow::ONE, glow::ZERO ),
CompositeMode::Dst => ( glow::ZERO, glow::ONE ),
CompositeMode::DstOver => ( glow::ONE_MINUS_DST_ALPHA, glow::ONE ),
CompositeMode::SrcIn => ( glow::DST_ALPHA, glow::ZERO ),
CompositeMode::DstIn => ( glow::ZERO, glow::SRC_ALPHA ),
CompositeMode::SrcOut => ( glow::ONE_MINUS_DST_ALPHA, glow::ZERO ),
CompositeMode::DstOut => ( glow::ZERO, glow::ONE_MINUS_SRC_ALPHA ),
CompositeMode::SrcAtop => ( glow::DST_ALPHA, glow::ONE_MINUS_SRC_ALPHA ),
CompositeMode::DstAtop => ( glow::ONE_MINUS_DST_ALPHA, glow::SRC_ALPHA ),
CompositeMode::Xor => ( glow::ONE_MINUS_DST_ALPHA, glow::ONE_MINUS_SRC_ALPHA ),
CompositeMode::Plus => ( glow::ONE, glow::ONE ),
CompositeMode::Multiply => ( glow::DST_COLOR, glow::ZERO ),
CompositeMode::Screen => ( glow::ONE, glow::ONE_MINUS_SRC_COLOR ),
};
// SAFETY: plain blend-state change on the current context.
unsafe { self.gl.blend_func( src, dst ); }
}
/// Return a borrowed descriptor for the FBO color texture
/// containing the latest rendered pixels.
///
/// `y_inverted` is `true`: the FBO uses GL's native lower-left
/// origin, so row 0 in texture memory is the bottom of the
/// rendered image. Consumers that follow the same convention flip
/// during sampling when this flag is set, producing a correctly-
/// oriented result. The CPU-side counterpart
/// [`Self::read_rgba_pixels`] does the same flip inline so the
/// byte buffer is top-down.
pub fn borrowed_texture( &self ) -> BorrowedGlesTexture
{
BorrowedGlesTexture
{
#[ cfg( not( target_arch = "wasm32" ) ) ]
texture_id: native_texture_id( self.fbo_tex ),
#[ cfg( not( target_arch = "wasm32" ) ) ]
framebuffer_id: native_framebuffer_id( self.fbo ),
texture: self.fbo_tex,
framebuffer: self.fbo,
width: self.width,
height: self.height,
premultiplied: true,
y_inverted: true,
}
}
/// Read the FBO color attachment into `out` as tightly packed RGBA8,
/// top-left row first.
///
/// This is a compatibility escape hatch. It forces a GPU→CPU sync and
/// should not be used in steady-state hot paths.
pub fn read_rgba_pixels( &self, out: &mut [u8] ) -> Result<(), String>
{
let needed = self.width as usize * self.height as usize * 4;
if out.len() < needed
{
return Err( format!(
"read_rgba_pixels needs {needed} bytes, got {}",
out.len(),
) );
}
let mut raw = vec![ 0_u8; needed ];
// SAFETY: `raw.len() == needed == width * height * 4` and PACK_ALIGNMENT
// is set to 1, so `read_pixels` writes exactly `needed` bytes into a
// buffer of exactly that size. `RGBA + UNSIGNED_BYTE` is the only
// guaranteed-readable format on every GLES2/3 driver.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.pixel_store_i32( glow::PACK_ALIGNMENT, 1 );
self.gl.read_pixels(
0,
0,
self.width as i32,
self.height as i32,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelPackData::Slice( Some( &mut raw ) ),
);
}
let stride = self.width as usize * 4;
for y in 0..self.height as usize
{
let src = ( self.height as usize - 1 - y ) * stride;
let dst = y * stride;
out[ dst..dst + stride ].copy_from_slice( &raw[ src..src + stride ] );
}
Ok( () )
}
/// Lazily allocate the auxiliary FBO+texture pair used as a snapshot of
/// `fbo` for framebuffer-fetch-style effects (Overlay blend,
/// backdrop-blur source). The pair is sized to match the canvas so
/// `gl_FragCoord.xy / canvas_size` samples the right texel.
///
/// Returns the texture handle of `aux_a`. The FBO is only needed for
/// the backdrop blur passes that write into `aux_b`; Overlay only
/// reads from `aux_a`, so this helper keeps the blur-only `aux_b`
/// allocation deferred until it is actually needed.
fn ensure_aux_a( &mut self ) -> glow::Texture
{
if self.aux_a.is_none()
{
// SAFETY: `alloc_fbo_tex` is `unsafe fn`; its requirement (current
// GL context) holds. Same FBO build / completeness assertion as
// `setup.rs::new`. We deliberately leave `aux_a`'s FBO as the live
// binding — the next draw goes through `activate_target` which
// rebinds `self.fbo`.
unsafe
{
let fbo = self.gl.create_framebuffer().expect( "aux_a FBO" );
let tex = alloc_fbo_tex( &self.gl, self.version, self.width, self.height );
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( fbo ) );
self.gl.framebuffer_texture_2d
(
glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D, Some( tex ), 0,
);
let status = self.gl.check_framebuffer_status( glow::FRAMEBUFFER );
assert_eq!( status, glow::FRAMEBUFFER_COMPLETE, "aux_a FBO incomplete: 0x{status:x}" );
self.aux_a = Some( ( fbo, tex ) );
}
}
self.aux_a.expect( "just allocated" ).1
}
/// Snapshot variant that additionally clamps `region` to the active
/// scissor. Safe only for shaders that sample the snapshot at
/// exactly one point per fragment.
pub( super ) fn snapshot_fbo_region_tight( &mut self, region: Rect )
{
self.snapshot_fbo_region_impl( region, true )
}
fn snapshot_fbo_region_impl( &mut self, region: Rect, intersect_scissor: bool )
{
let aux_tex = self.ensure_aux_a();
// Clamp `region` to canvas bounds. `copy_tex_sub_image_2d` would
// generate `INVALID_VALUE` (or undefined behaviour on some
// drivers) if the source rect extends outside the framebuffer.
let cw = self.width as f32;
let ch = self.height as f32;
let mut x0 = region.x.max( 0.0 );
let mut y0_top = region.y.max( 0.0 );
let mut x1 = ( region.x + region.width ).min( cw );
let mut y1_top = ( region.y + region.height ).min( ch );
if intersect_scissor
{
if let Some( clip ) = self.clip_scissor
{
x0 = x0.max( clip.x );
y0_top = y0_top.max( clip.y );
x1 = x1.min( clip.x + clip.width );
y1_top = y1_top.min( clip.y + clip.height );
}
}
let w = ( x1 - x0 ).floor() as i32;
let h = ( y1_top - y0_top ).floor() as i32;
if w <= 0 || h <= 0 { return; }
// GL framebuffer origin is bottom-left; our rect is top-left.
let src_x = x0.floor() as i32;
let src_y = self.height as i32 - y0_top.floor() as i32 - h;
// SAFETY: the four bounds checks above guarantee `(src_x, src_y, w, h)`
// lies fully inside `self.fbo`'s colour attachment, so
// `copy_tex_sub_image_2d` will not raise INVALID_VALUE. `aux_tex` was
// allocated through `ensure_aux_a` to canvas dimensions, so the
// destination region is also in-bounds.
unsafe
{
self.gl.bind_framebuffer( glow::FRAMEBUFFER, Some( self.fbo ) );
self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) );
self.gl.copy_tex_sub_image_2d
(
glow::TEXTURE_2D, 0,
src_x, src_y,
src_x, src_y,
w, h,
);
self.gl.bind_texture( glow::TEXTURE_2D, None );
}
}
/// Drop both auxiliary FBO+texture pairs if allocated. Called from
/// [`Self::resize`] so the next effect re-allocates at the new size.
pub( super ) fn invalidate_aux( &mut self )
{
// SAFETY: each (fbo, tex) pair was created through `self.gl` in
// `ensure_aux_a` / `ensure_aux_b`, so deleting through the same
// context is well-defined. `take()` ensures we never double-delete.
unsafe
{
if let Some( ( fbo, tex ) ) = self.aux_a.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
if let Some( ( fbo, tex ) ) = self.aux_b.take()
{
self.gl.delete_framebuffer( fbo );
self.gl.delete_texture( tex );
}
}
}
/// Blit the FBO color attachment onto the default framebuffer (the EGL
/// window). Caller is responsible for the `eglSwapBuffers` that
/// publishes the result. After present, the FBO is rebound so the next
/// frame's draws keep accumulating into the shadow canvas.
///
/// The blit always covers the full surface — partial-redraw still saves
/// work upstream (only changed widget pixels are repainted into the
/// FBO), but the FBO→FB0 transfer itself is a single cheap fullscreen
/// op.
pub fn present( &mut self )
{
self.present_to( self.width, self.height );
}
/// [`Self::present`] onto a default framebuffer of `width × height`, which the quad is stretched to cover.
pub fn present_to( &mut self, width: u32, height: u32 )
{
// Scoped guards: bind the default framebuffer (id 0) and the
// blit program for the duration of this fn. On Drop they restore
// `self.fbo` and "no program", so any future early-return / panic
// in the blit body cannot leave the canvas pointing at the wrong
// FBO or program. The viewport, blend and scissor are restored
// inline below — they are non-resource state that does not need
// the guard treatment because `activate_target` rewrites them on
// every subsequent draw.
//
// SAFETY: GL context is current (canvas invariant). `self.fbo` is
// the canvas-owned FBO from `setup.rs::new`. `self.blit_program`
// was linked in `setup.rs::new`. Restoring "no program active"
// (`None`) is always sound.
let _fbo = unsafe { FboBinding::scoped( &self.gl, None, Some( self.fbo ) ) };
let _prog = unsafe { ProgramBinding::scoped( &self.gl, Some( self.blit_program ), None ) };
// SAFETY: see above. The block sets up the blit pipeline state,
// draws a fullscreen quad sampling `fbo_tex`, then restores blend
// and viewport so the next frame's draws inherit the canvas-wide
// defaults. FBO + program are restored by the guards on scope exit.
unsafe
{
self.gl.viewport( 0, 0, width as i32, height as i32 );
self.gl.disable( glow::SCISSOR_TEST );
self.gl.disable( glow::BLEND );
self.gl.active_texture( glow::TEXTURE0 );
self.gl.bind_texture( glow::TEXTURE_2D, Some( self.fbo_tex ) );
self.gl.uniform_1_i32( Some( &self.u_blit_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 );
self.gl.enable( glow::BLEND );
self.gl.viewport( 0, 0, self.width as i32, self.height as i32 );
}
// Scissor was disabled above; reflect that in our cached state.
self.clip_scissor = None;
// Guards drop here: FBO → self.fbo, program → None.
}
}