Skip to main content

pane/
draw.rs

1use crate::input::Input;
2use crate::textures::{TextureId, TextureRegistry, UvRect};
3use glyphon::{
4    Attrs, Buffer, Cache, Color as GlyphColor, Family, FontSystem, Metrics, Shaping, SwashCache,
5    TextArea, TextAtlas, TextBounds, TextRenderer, Viewport,
6};
7use std::collections::HashMap;
8use std::sync::Arc;
9use winit::{
10    application::ApplicationHandler,
11    event::WindowEvent,
12    event_loop::{ActiveEventLoop, EventLoop},
13    window::{Window, WindowId},
14};
15
16// ── Scaling ───────────────────────────────────────────────────────────────────
17
18/// Convert a grid-space point to normalised device coordinates `[x, y]`.
19///
20/// `gx`/`gy` are in the 1080-unit grid (origin at screen centre, y-up negative).
21/// `pw`/`ph` are the physical pixel dimensions of the render target.
22#[must_use]
23pub fn to_ndc(gx: f32, gy: f32, pw: f32, ph: f32) -> [f32; 2] {
24    let gw = grid_width(pw, ph);
25    let unit = ph / 1080.0;
26    let ox = gw.mul_add(-unit, pw) * 0.5;
27    let px = gw.mul_add(0.5, gx).mul_add(unit, ox);
28    let py = (gy + 540.0) * unit;
29    [(px / pw).mul_add(2.0, -1.0), (py / ph).mul_add(-2.0, 1.0)]
30}
31
32/// Return the grid-space width for a window of size `pw × ph` pixels.
33///
34/// Height is always 1080 units; width scales proportionally with the aspect ratio.
35#[must_use]
36pub fn grid_width(pw: f32, ph: f32) -> f32 {
37    (pw / ph) * 1080.0
38}
39
40// ── Color ─────────────────────────────────────────────────────────────────────
41
42/// Linear RGBA colour with components in `0.0..=1.0`.
43#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)]
44pub struct Color {
45    pub r: f32,
46    pub g: f32,
47    pub b: f32,
48    pub a: f32,
49}
50
51impl Color {
52    /// Construct a colour from linear RGBA components.
53    #[must_use]
54    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
55        Self { r, g, b, a }
56    }
57    pub const WHITE: Self = Self::rgba(1.0, 1.0, 1.0, 1.0);
58
59    fn to_glyph(self) -> GlyphColor {
60        #[expect(
61            clippy::cast_possible_truncation,
62            clippy::cast_sign_loss,
63            reason = "value is clamped to 0.0..=255.0 and rounded before cast"
64        )]
65        fn channel(v: f32) -> u8 {
66            (v * 255.0).clamp(0.0, 255.0).round() as u8
67        }
68        GlyphColor::rgba(
69            channel(self.r),
70            channel(self.g),
71            channel(self.b),
72            channel(self.a),
73        )
74    }
75}
76
77// ── IDs / Rects ───────────────────────────────────────────────────────────────
78
79/// Axis-aligned rectangle in grid coordinates.
80#[derive(Debug, Clone, Copy)]
81pub struct Rect {
82    pub x: f32,
83    pub y: f32,
84    pub w: f32,
85    pub h: f32,
86}
87
88impl Rect {
89    /// Construct a rect from its top-left corner and dimensions (grid units).
90    #[must_use]
91    pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
92        Self { x, y, w, h }
93    }
94
95    /// Return `true` if the grid-space point `(px, py)` falls inside this rect.
96    #[must_use]
97    pub fn contains(&self, px: f32, py: f32) -> bool {
98        px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h
99    }
100}
101
102/// Opaque handle to a compiled render pipeline, returned by [`Pane::register_shader`].
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub struct ShaderId(usize);
105
106impl ShaderId {
107    /// Wrap a pipeline-vec index. Only called from [`Pane::register_shader`].
108    pub(crate) const fn new(index: usize) -> Self {
109        Self(index)
110    }
111    /// Unwrap to the pipeline-vec index for use in draw calls.
112    pub(crate) const fn index(self) -> usize {
113        self.0
114    }
115}
116
117/// Axis-aligned scissor rectangle in grid coordinates.
118///
119/// When a batch has a `ClipRect`, fragments outside it are discarded by the GPU
120/// scissor test and by glyphon's `TextBounds`. Both use `clip_to_pixels` to
121/// convert to physical pixel coordinates.
122#[derive(Debug, Clone, Copy, PartialEq)]
123pub struct ClipRect {
124    pub x: f32,
125    pub y: f32,
126    pub w: f32,
127    pub h: f32,
128}
129
130// ── Coordinate helpers ────────────────────────────────────────────────────────
131
132/// Convert a [`ClipRect`] (grid coordinates) to pixel-space `(left, top, right, bottom)`.
133/// This is the single source of truth used by both the text bounds and the scissor rect.
134fn clip_to_pixels(c: ClipRect, pw: f32, ph: f32) -> (f32, f32, f32, f32) {
135    let unit = ph / 1080.0;
136    let gw = grid_width(pw, ph);
137    let ox = gw.mul_add(-unit, pw) * 0.5;
138    let left = gw.mul_add(0.5, c.x).mul_add(unit, ox);
139    let top = (c.y + 540.0) * unit;
140    let right = gw.mul_add(0.5, c.x + c.w).mul_add(unit, ox);
141    let bottom = (c.y + c.h + 540.0) * unit;
142    (left, top, right, bottom)
143}
144
145// ── Batch ─────────────────────────────────────────────────────────────────────
146
147struct Batch {
148    shader: ShaderId,
149    verts: Vec<[f32; 2]>,
150    color: Color,
151    z: f32,
152    clip: Option<ClipRect>,
153    texture: Option<TextureId>,
154    uv_rect: Option<UvRect>,
155    corner_radius: f32,
156    border_width: f32,
157    state: [f32; 4],
158}
159
160// ── Text Alignment ────────────────────────────────────────────────────────────
161
162/// Horizontal alignment for text drawn inside a fixed-width box.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, Default)]
164pub enum TextAlign {
165    Left,
166    #[default]
167    Center,
168    Right,
169}
170
171/// Per-batch draw metadata: (shader, vertex range, clip rect, texture, z-depth).
172type BatchRange = (
173    ShaderId,
174    std::ops::Range<u32>,
175    Option<ClipRect>,
176    Option<TextureId>,
177    f32,
178);
179
180/// Parameters for a single text draw call.
181pub(crate) struct TextDraw<'a> {
182    pub text: &'a str,
183    pub x: f32,
184    pub y: f32,
185    pub w: f32,
186    pub size: f32,
187    pub color: Color,
188    pub align: TextAlign,
189    pub font: Option<&'a str>,
190    pub bold: bool,
191    pub italic: bool,
192    pub clip: Option<ClipRect>,
193    pub z: f32,
194}
195
196/// GPU frame resources passed to `Pane::render`.
197pub(crate) struct GpuFrame<'a> {
198    pub encoder: &'a mut wgpu::CommandEncoder,
199    pub view: &'a wgpu::TextureView,
200    pub device: &'a wgpu::Device,
201    pub queue: &'a wgpu::Queue,
202}
203
204// ── Scene ─────────────────────────────────────────────────────────────────────
205
206struct TextEntry {
207    text: String,
208    x: f32,
209    y: f32,
210    w: f32,
211    size: f32,
212    color: Color,
213    align: TextAlign,
214    font: Option<String>,
215    bold: bool,
216    italic: bool,
217    clip: Option<ClipRect>,
218    z: f32,
219}
220
221/// Batched draw list assembled each frame before it is handed to `Pane::render`.
222///
223/// Widgets append geometry via the `push_*` / `text*` methods.  The renderer
224/// sorts by z, uploads all vertices in one write, then issues draw calls.
225/// Call [`Scene::clear`] at the start of each frame to reuse the allocation.
226pub struct Scene {
227    batches: Vec<Batch>,
228    texts: Vec<TextEntry>,
229}
230
231impl Scene {
232    /// Create an empty scene.
233    #[must_use]
234    pub const fn new() -> Self {
235        Self {
236            batches: Vec::new(),
237            texts: Vec::new(),
238        }
239    }
240
241    /// Push a plain geometry batch (no border, no widget state).
242    ///
243    /// Shorthand for [`push_widget`](Scene::push_widget) with `border_width = 0` and `state = [0; 4]`.
244    pub fn push_full(
245        &mut self,
246        verts: Vec<[f32; 2]>,
247        shader: ShaderId,
248        color: Color,
249        z: f32,
250        clip: Option<ClipRect>,
251        corner_radius: f32,
252    ) {
253        self.push_widget(verts, shader, color, z, clip, corner_radius, 0.0, [0.0; 4]);
254    }
255
256    /// Push a widget geometry batch with full control over all vertex attributes.
257    ///
258    /// `state` is passed through to the shader as `@location(5)`:
259    /// `[hovered, pressed, focused, disabled]`, each `0.0` or `1.0`.
260    pub fn push_widget(
261        &mut self,
262        verts: Vec<[f32; 2]>,
263        shader: ShaderId,
264        color: Color,
265        z: f32,
266        clip: Option<ClipRect>,
267        corner_radius: f32,
268        border_width: f32,
269        state: [f32; 4],
270    ) {
271        self.batches.push(Batch {
272            shader,
273            verts,
274            color,
275            z,
276            clip,
277            texture: None,
278            uv_rect: None,
279            corner_radius,
280            border_width,
281            state,
282        });
283    }
284
285    /// Push a textured quad using the full texture (UV `0,0 → 1,1`).
286    pub fn push_image(
287        &mut self,
288        rect: Rect,
289        shader: ShaderId,
290        texture: TextureId,
291        z: f32,
292        clip: Option<ClipRect>,
293    ) {
294        self.push_image_uv(rect, shader, texture, z, clip, None);
295    }
296
297    /// Push a textured quad with an optional sub-rect UV override (for sprite sheets / GIF frames).
298    pub fn push_image_uv(
299        &mut self,
300        rect: Rect,
301        shader: ShaderId,
302        texture: TextureId,
303        depth: f32,
304        clip: Option<ClipRect>,
305        uv_rect: Option<UvRect>,
306    ) {
307        let (rx, ry, rw, rh) = (rect.x, rect.y, rect.w, rect.h);
308        let verts = vec![
309            [rx, ry],
310            [rx + rw, ry],
311            [rx, ry + rh],
312            [rx + rw, ry],
313            [rx + rw, ry + rh],
314            [rx, ry + rh],
315        ];
316        self.batches.push(Batch {
317            shader,
318            verts,
319            color: Color::WHITE,
320            z: depth,
321            clip,
322            texture: Some(texture),
323            uv_rect,
324            corner_radius: 0.0,
325            border_width: 0.0,
326            state: [0.0; 4],
327        });
328    }
329
330    /// Draw center-aligned text in a box of width `w` at grid position `(x, y)`.
331    pub fn text(&mut self, text: &str, x: f32, y: f32, w: f32, size: f32, color: Color) {
332        self.push_text(&TextDraw {
333            text,
334            x,
335            y,
336            w,
337            size,
338            color,
339            align: TextAlign::Center,
340            font: None,
341            bold: false,
342            italic: false,
343            clip: None,
344            z: 0.5,
345        });
346    }
347
348    /// Draw left-aligned text starting at grid position `(x, y)` with no width constraint.
349    pub fn text_left(&mut self, text: &str, x: f32, y: f32, size: f32, color: Color) {
350        self.push_text(&TextDraw {
351            text,
352            x,
353            y,
354            w: 0.0,
355            size,
356            color,
357            align: TextAlign::Left,
358            font: None,
359            bold: false,
360            italic: false,
361            clip: None,
362            z: 0.5,
363        });
364    }
365
366    /// Full control: explicit alignment, optional font family, bold, italic, clip, z-order.
367    pub(crate) fn push_text(&mut self, p: &TextDraw<'_>) {
368        self.texts.push(TextEntry {
369            text: p.text.to_string(),
370            x: p.x,
371            y: p.y,
372            w: p.w,
373            size: p.size,
374            color: p.color,
375            align: p.align,
376            font: p.font.map(std::string::ToString::to_string),
377            bold: p.bold,
378            italic: p.italic,
379            clip: p.clip,
380            z: p.z,
381        });
382    }
383
384    /// Discard all queued geometry and text — call at the start of each frame.
385    pub fn clear(&mut self) {
386        self.batches.clear();
387        self.texts.clear();
388    }
389    /// Return `true` if no geometry or text has been queued this frame.
390    #[must_use]
391    pub const fn is_empty(&self) -> bool {
392        self.batches.is_empty() && self.texts.is_empty()
393    }
394}
395
396impl Default for Scene {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402// ── Vertex Layout ─────────────────────────────────────────────────────────────
403
404const VERT_SIZE: u64 = 64;
405const MAX_VERTS: u64 = 2_000_000;
406
407// Globals uniform: [time, dt, frame, cursor_x, cursor_y, cursor_pressed, pad0, pad1]
408const GLOBALS_SIZE: u64 = (std::mem::size_of::<f32>() * 8) as u64;
409
410// ── Text System ───────────────────────────────────────────────────────────────
411
412struct TextSystem {
413    font_system: FontSystem,
414    swash_cache: SwashCache,
415    _cache: Cache,
416    atlas: TextAtlas,
417    renderer: TextRenderer,
418    viewport: Viewport,
419}
420
421impl TextSystem {
422    fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
423        let font_system = FontSystem::new();
424        let swash_cache = SwashCache::new();
425        let cache = Cache::new(device);
426        let viewport = Viewport::new(device, &cache);
427        let mut atlas = TextAtlas::new(device, queue, &cache, format);
428        let renderer =
429            TextRenderer::new(&mut atlas, device, wgpu::MultisampleState::default(), None);
430        Self {
431            font_system,
432            swash_cache,
433            _cache: cache,
434            atlas,
435            renderer,
436            viewport,
437        }
438    }
439
440    fn build_buffer(&mut self, t: &TextEntry, unit: f32, pw: f32, ph: f32) -> Buffer {
441        let font_size = t.size * unit;
442        let use_left = matches!(t.align, TextAlign::Left) || t.w <= 0.0;
443        let buf_w = if use_left { pw } else { t.w * unit };
444        let family = t.font.as_deref().map_or(Family::SansSerif, Family::Name);
445        let weight = if t.bold {
446            glyphon::Weight::BOLD
447        } else {
448            glyphon::Weight::NORMAL
449        };
450        let style = if t.italic {
451            glyphon::Style::Italic
452        } else {
453            glyphon::Style::Normal
454        };
455        let mut buf = Buffer::new(
456            &mut self.font_system,
457            Metrics::new(font_size, font_size * 1.2),
458        );
459        buf.set_size(&mut self.font_system, Some(buf_w), Some(ph));
460        buf.set_text(
461            &mut self.font_system,
462            &t.text,
463            &Attrs::new().family(family).weight(weight).style(style),
464            Shaping::Advanced,
465            None,
466        );
467        let align = match t.align {
468            TextAlign::Left => glyphon::cosmic_text::Align::Left,
469            TextAlign::Center => glyphon::cosmic_text::Align::Center,
470            TextAlign::Right => glyphon::cosmic_text::Align::Right,
471        };
472        for line in &mut buf.lines {
473            line.set_align(Some(align));
474        }
475        buf.shape_until_scroll(&mut self.font_system, false);
476        buf
477    }
478
479    fn build_text_area<'a>(
480        t: &TextEntry,
481        buf: &'a Buffer,
482        unit: f32,
483        pw: f32,
484        ph: f32,
485        gw: f32,
486        ox: f32,
487    ) -> TextArea<'a> {
488        let bounds = t.clip.map_or_else(
489            || TextBounds {
490                left: 0,
491                top: 0,
492                right: pw.max(0.0).trunc() as i32,
493                bottom: ph.max(0.0).trunc() as i32,
494            },
495            |c| {
496                let (l, t, r, b) = clip_to_pixels(c, pw, ph);
497                TextBounds {
498                    left: l.trunc() as i32,
499                    top: t.trunc() as i32,
500                    right: r.trunc() as i32,
501                    bottom: b.trunc() as i32,
502                }
503            },
504        );
505        TextArea {
506            buffer: buf,
507            left: gw.mul_add(0.5, t.x).mul_add(unit, ox),
508            top: (t.y + 540.0) * unit,
509            scale: 1.0,
510            bounds,
511            default_color: t.color.to_glyph(),
512            custom_glyphs: &[],
513        }
514    }
515
516    /// Returns the x offset **in pixels** (from the buffer left) where the cursor
517    /// at `cursor_byte` falls, using the exact same buffer configuration that the
518    /// renderer uses — same `buf_w`, same `ph`, same alignment — so glyph positions
519    /// are guaranteed to match the rendered text.  The alignment offset is already
520    /// baked into the returned value; add it to the text origin to get screen x.
521    pub(crate) fn measure_cursor_px(
522        &mut self,
523        text: &str,
524        font_size_px: f32,
525        buf_w_px: f32,
526        _ph: f32,
527        align: TextAlign,
528        cursor_byte: usize,
529        bold: bool,
530        italic: bool,
531        font: Option<&str>,
532    ) -> f32 {
533        let family = font.map_or(Family::SansSerif, Family::Name);
534        let weight = if bold {
535            glyphon::Weight::BOLD
536        } else {
537            glyphon::Weight::NORMAL
538        };
539        let style = if italic {
540            glyphon::Style::Italic
541        } else {
542            glyphon::Style::Normal
543        };
544        let mut buf = Buffer::new(
545            &mut self.font_system,
546            Metrics::new(font_size_px, font_size_px * 1.2),
547        );
548        // Use the same width as build_buffer. Height is unbounded — we only
549        // care about x positions on the first line, and omitting height avoids
550        // any scroll-based clipping that could shift glyph layout.
551        buf.set_size(&mut self.font_system, Some(buf_w_px), None);
552        buf.set_text(
553            &mut self.font_system,
554            text,
555            &Attrs::new().family(family).weight(weight).style(style),
556            Shaping::Advanced,
557            None,
558        );
559        let cosmic_align = match align {
560            TextAlign::Left => glyphon::cosmic_text::Align::Left,
561            TextAlign::Center => glyphon::cosmic_text::Align::Center,
562            TextAlign::Right => glyphon::cosmic_text::Align::Right,
563        };
564        for line in &mut buf.lines {
565            line.set_align(Some(cosmic_align));
566        }
567        buf.shape_until_scroll(&mut self.font_system, false);
568
569        for run in buf.layout_runs() {
570            let glyphs = run.glyphs;
571            for (i, glyph) in glyphs.iter().enumerate() {
572                if cursor_byte >= glyph.start && cursor_byte < glyph.end {
573                    return glyph.x;
574                }
575                if cursor_byte == glyph.end {
576                    return glyphs.get(i + 1).map_or(glyph.x + glyph.w, |next| next.x);
577                }
578            }
579        }
580        // Cursor past all glyphs (empty text or end of text).
581        buf.layout_runs()
582            .last()
583            .and_then(|r| r.glyphs.last())
584            .map_or_else(
585                || match align {
586                    TextAlign::Center => buf_w_px * 0.5,
587                    TextAlign::Right => buf_w_px,
588                    TextAlign::Left => 0.0,
589                },
590                |g| g.x + g.w,
591            )
592    }
593
594    fn render(
595        &mut self,
596        device: &wgpu::Device,
597        queue: &wgpu::Queue,
598        pass: &mut wgpu::RenderPass,
599        texts: &[TextEntry],
600        pw: f32,
601        ph: f32,
602    ) {
603        if texts.is_empty() {
604            return;
605        }
606        let unit = ph / 1080.0;
607        let ox = grid_width(pw, ph).mul_add(-unit, pw) * 0.5; // horizontal letterbox offset, mirrors to_ndc
608        #[expect(
609            clippy::cast_possible_truncation,
610            clippy::cast_sign_loss,
611            reason = "viewport dimensions are clamped to >=0 before cast"
612        )]
613        self.viewport.update(
614            queue,
615            glyphon::Resolution {
616                width: pw.max(0.0) as u32,
617                height: ph.max(0.0) as u32,
618            },
619        );
620
621        let buffers: Vec<Buffer> = texts
622            .iter()
623            .map(|t| self.build_buffer(t, unit, pw, ph))
624            .collect();
625
626        let gw = grid_width(pw, ph);
627        let areas: Vec<TextArea> = texts
628            .iter()
629            .zip(buffers.iter())
630            .map(|(t, buf)| Self::build_text_area(t, buf, unit, pw, ph, gw, ox))
631            .collect();
632
633        self.atlas.trim();
634        if let Err(e) = self.renderer.prepare(
635            device,
636            queue,
637            &mut self.font_system,
638            &mut self.atlas,
639            &self.viewport,
640            areas,
641            &mut self.swash_cache,
642        ) {
643            eprintln!("[pane] text prepare error: {e}");
644            return;
645        }
646        if let Err(e) = self.renderer.render(&self.atlas, &self.viewport, pass) {
647            eprintln!("[pane] text render error: {e}");
648        }
649    }
650}
651
652// ── Pane (standalone + overlay renderer) ─────────────────────────────────────
653//
654// Owns pipelines, vertex buffer, textures, and text system.
655// In overlay mode the caller provides device/queue each frame.
656// In standalone mode Pane owns device/queue/surface directly.
657//
658// GpuResources bundles the fields that are always initialised together and are
659// always Some after construction. This eliminates Option-unwrap noise throughout
660// the impl — if you have a &GpuResources the compiler knows it exists.
661//
662// StandaloneReady holds the four fields that only exist in standalone mode.
663// Grouping them in an Option<StandaloneReady> means the compiler enforces that
664// they are all-present or all-absent, removing the risk of partial init.
665
666struct StandaloneReady {
667    surface: wgpu::Surface<'static>,
668    config: wgpu::SurfaceConfiguration,
669    device: Arc<wgpu::Device>,
670    queue: Arc<wgpu::Queue>,
671}
672
673struct GpuResources {
674    vertex_buf: wgpu::Buffer,
675    text: TextSystem,
676    text_overlay: TextSystem,
677    globals_bgl: wgpu::BindGroupLayout,
678    globals_buf: wgpu::Buffer,
679    globals_bg: wgpu::BindGroup,
680}
681
682/// wgpu renderer for a single render target.
683///
684/// Operates in two modes:
685/// - **Overlay** — created with [`Pane::new`]; the caller owns the surface and
686///   supplies a `GpuFrame` each call to `Pane::render`.
687/// - **Standalone** — created internally by [`run`]; `Pane` owns the window,
688///   surface, device, and queue and drives its own event loop.
689pub struct Pane {
690    pipelines: Vec<wgpu::RenderPipeline>,
691    shader_cache: HashMap<String, ShaderId>,
692    gpu: Option<GpuResources>, // None only in standalone before init()
693    format: wgpu::TextureFormat,
694    standalone: Option<StandaloneReady>, // None in overlay mode
695    total_time: f32,
696    frame_count: u32,
697}
698
699impl Pane {
700    const fn gpu(&self) -> &GpuResources {
701        self.gpu.as_ref().unwrap()
702    }
703    const fn gpu_mut(&mut self) -> &mut GpuResources {
704        self.gpu.as_mut().unwrap()
705    }
706
707    fn make_gpu(
708        device: &wgpu::Device,
709        queue: &wgpu::Queue,
710        format: wgpu::TextureFormat,
711    ) -> GpuResources {
712        let vertex_buf = device.create_buffer(&wgpu::BufferDescriptor {
713            label: None,
714            size: MAX_VERTS * VERT_SIZE,
715            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
716            mapped_at_creation: false,
717        });
718        let globals_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
719            label: None,
720            entries: &[wgpu::BindGroupLayoutEntry {
721                binding: 0,
722                visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
723                ty: wgpu::BindingType::Buffer {
724                    ty: wgpu::BufferBindingType::Uniform,
725                    has_dynamic_offset: false,
726                    min_binding_size: None,
727                },
728                count: None,
729            }],
730        });
731        let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
732            label: None,
733            size: GLOBALS_SIZE,
734            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
735            mapped_at_creation: false,
736        });
737        let globals_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
738            label: None,
739            layout: &globals_bgl,
740            entries: &[wgpu::BindGroupEntry {
741                binding: 0,
742                resource: globals_buf.as_entire_binding(),
743            }],
744        });
745        GpuResources {
746            vertex_buf,
747            text: TextSystem::new(device, queue, format),
748            text_overlay: TextSystem::new(device, queue, format),
749            globals_bgl,
750            globals_buf,
751            globals_bg,
752        }
753    }
754
755    /// Create for overlay mode — caller provides device/queue.
756    #[must_use]
757    pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
758        Self {
759            pipelines: Vec::new(),
760            shader_cache: HashMap::new(),
761            gpu: Some(Self::make_gpu(device, queue, format)),
762            format,
763            standalone: None,
764            total_time: 0.0,
765            frame_count: 0,
766        }
767    }
768
769    /// Create an uninitialised shell for standalone mode. Call `init()` after window is ready.
770    fn new_standalone() -> Self {
771        Self {
772            pipelines: Vec::new(),
773            shader_cache: HashMap::new(),
774            gpu: None,
775            format: wgpu::TextureFormat::Bgra8UnormSrgb,
776            standalone: None,
777            total_time: 0.0,
778            frame_count: 0,
779        }
780    }
781
782    async fn init(&mut self, window: Arc<Window>) -> Result<(), String> {
783        let size = window.inner_size();
784        let instance = wgpu::Instance::default();
785        let surface = instance
786            .create_surface(window)
787            .map_err(|e| format!("[pane] failed to create surface: {e}"))?;
788        let adapter = instance
789            .request_adapter(&wgpu::RequestAdapterOptions {
790                compatible_surface: Some(&surface),
791                ..Default::default()
792            })
793            .await
794            .map_err(|e| format!("[pane] no compatible GPU adapter found: {e}"))?;
795        let (device, queue) = adapter
796            .request_device(&wgpu::DeviceDescriptor::default())
797            .await
798            .map_err(|e| format!("[pane] failed to acquire GPU device: {e}"))?;
799        let cap = surface.get_capabilities(&adapter);
800        // Prefer an sRGB format so all colors (textures, clear color, shaders) are
801        // gamma-corrected automatically by the GPU. Fall back to whatever is available.
802        let format = cap
803            .formats
804            .iter()
805            .find(|f| f.is_srgb())
806            .copied()
807            .unwrap_or(cap.formats[0]);
808        let config = wgpu::SurfaceConfiguration {
809            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
810            format,
811            width: size.width,
812            height: size.height,
813            present_mode: wgpu::PresentMode::Fifo,
814            alpha_mode: cap.alpha_modes[0],
815            view_formats: vec![],
816            desired_maximum_frame_latency: 2,
817        };
818        surface.configure(&device, &config);
819
820        let device = Arc::new(device);
821        let queue = Arc::new(queue);
822        self.gpu = Some(Self::make_gpu(&device, &queue, format));
823        self.format = format;
824        self.standalone = Some(StandaloneReady {
825            surface,
826            config,
827            device,
828            queue,
829        });
830        Ok(())
831    }
832
833    /// Advance `total_time`/`frame_count`, write the globals uniform, and tick textures.
834    /// Must be called once per frame regardless of whether the scene is empty.
835    fn advance_time(
836        &mut self,
837        dt: f32,
838        pw: f32,
839        ph: f32,
840        cursor: [f32; 3],
841        queue: &wgpu::Queue,
842        tex_reg: &mut TextureRegistry,
843    ) {
844        self.total_time += dt;
845        self.frame_count = self.frame_count.wrapping_add(1);
846        let [cx, cy] = to_ndc(cursor[0], cursor[1], pw, ph);
847        let globals_data: [f32; 8] = [
848            self.total_time,
849            dt,
850            self.frame_count as f32,
851            cx,
852            cy,
853            cursor[2],
854            0.0,
855            0.0,
856        ];
857        queue.write_buffer(
858            &self.gpu().globals_buf,
859            0,
860            bytemuck::cast_slice(&globals_data),
861        );
862        tex_reg.update(dt);
863    }
864
865    /// Return the wgpu device when running in standalone mode, or `None` in overlay mode.
866    pub(crate) fn device(&self) -> Option<&Arc<wgpu::Device>> {
867        self.standalone.as_ref().map(|s| &s.device)
868    }
869
870    /// Return the wgpu queue when running in standalone mode, or `None` in overlay mode.
871    pub(crate) fn queue(&self) -> Option<&Arc<wgpu::Queue>> {
872        self.standalone.as_ref().map(|s| &s.queue)
873    }
874
875    /// Compile a WGSL shader and register it as a render pipeline, returning its [`ShaderId`].
876    ///
877    /// Results are cached by source string, so calling this with the same WGSL twice is free.
878    /// The pipeline is configured for the vertex layout used by [`Scene`]: positions, UVs,
879    /// colour, corner radius / border width, widget size, and widget state.
880    pub fn register_shader(
881        &mut self,
882        device: &wgpu::Device,
883        wgsl: &str,
884        tex_reg: &TextureRegistry,
885    ) -> ShaderId {
886        if let Some(&id) = self.shader_cache.get(wgsl) {
887            return id;
888        }
889
890        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
891            label: None,
892            source: wgpu::ShaderSource::Wgsl(wgsl.into()),
893        });
894        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
895            label: None,
896            bind_group_layouts: &[
897                Some(&tex_reg.bind_group_layout),
898                Some(&self.gpu().globals_bgl),
899            ],
900            immediate_size: 0,
901        });
902        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
903            label: None,
904            layout: Some(&layout),
905            vertex: wgpu::VertexState {
906                module: &shader,
907                entry_point: Some("vs"),
908                buffers: &[wgpu::VertexBufferLayout {
909                    array_stride: VERT_SIZE,
910                    step_mode: wgpu::VertexStepMode::Vertex,
911                    attributes: &[
912                        wgpu::VertexAttribute {
913                            offset: 0,
914                            shader_location: 0,
915                            format: wgpu::VertexFormat::Float32x2,
916                        },
917                        wgpu::VertexAttribute {
918                            offset: 8,
919                            shader_location: 1,
920                            format: wgpu::VertexFormat::Float32x2,
921                        },
922                        wgpu::VertexAttribute {
923                            offset: 16,
924                            shader_location: 2,
925                            format: wgpu::VertexFormat::Float32x4,
926                        },
927                        wgpu::VertexAttribute {
928                            offset: 32,
929                            shader_location: 3,
930                            format: wgpu::VertexFormat::Float32x2,
931                        },
932                        wgpu::VertexAttribute {
933                            offset: 40,
934                            shader_location: 4,
935                            format: wgpu::VertexFormat::Float32x2,
936                        },
937                        wgpu::VertexAttribute {
938                            offset: 48,
939                            shader_location: 5,
940                            format: wgpu::VertexFormat::Float32x4,
941                        },
942                    ],
943                }],
944                compilation_options: wgpu::PipelineCompilationOptions::default(),
945            },
946            fragment: Some(wgpu::FragmentState {
947                module: &shader,
948                entry_point: Some("fs"),
949                targets: &[Some(wgpu::ColorTargetState {
950                    format: self.format,
951                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
952                    write_mask: wgpu::ColorWrites::ALL,
953                })],
954                compilation_options: wgpu::PipelineCompilationOptions::default(),
955            }),
956            primitive: wgpu::PrimitiveState::default(),
957            depth_stencil: None,
958            multisample: wgpu::MultisampleState::default(),
959            multiview_mask: None,
960            cache: None,
961        });
962
963        let id = ShaderId::new(self.pipelines.len());
964        self.pipelines.push(pipeline);
965        self.shader_cache.insert(wgsl.to_string(), id);
966        id
967    }
968
969    /// Returns the cursor x offset in **grid units** from the text-draw origin
970    /// (`rect.x + text_offset_x - scroll_offset`).  Uses the same buffer config as
971    /// the renderer so glyph positions are guaranteed to match.
972    ///
973    /// For left-aligned text pass `rect_w` = 0.0 (`buf_w` will be the window width).
974    pub(crate) fn measure_cursor(
975        &mut self,
976        text: &str,
977        font_size_grid: f32,
978        rect_w_grid: f32,
979        align: TextAlign,
980        pw: f32,
981        ph: f32,
982        cursor_byte: usize,
983        bold: bool,
984        italic: bool,
985        font: Option<&str>,
986    ) -> f32 {
987        let unit = ph / 1080.0;
988        let font_size_px = font_size_grid * unit;
989        // Match build_buffer: left-align uses full window width, others use rect width.
990        let buf_w_px = match align {
991            TextAlign::Left => pw,
992            _ => (rect_w_grid * unit).max(1.0),
993        };
994        self.gpu_mut().text.measure_cursor_px(
995            text,
996            font_size_px,
997            buf_w_px,
998            ph,
999            align,
1000            cursor_byte,
1001            bold,
1002            italic,
1003            font,
1004        ) / unit
1005    }
1006
1007    /// Render into an existing encoder+view (overlay mode).
1008    pub(crate) fn render(
1009        &mut self,
1010        frame: GpuFrame<'_>,
1011        scene: &mut Scene,
1012        pw: f32,
1013        ph: f32,
1014        dt: f32,
1015        cursor: [f32; 3],
1016        clear_color: Option<crate::draw::Color>,
1017        tex_reg: &mut TextureRegistry,
1018    ) {
1019        let GpuFrame {
1020            encoder,
1021            view,
1022            device,
1023            queue,
1024        } = frame;
1025        self.advance_time(dt, pw, ph, cursor, queue, tex_reg);
1026        if scene.is_empty() {
1027            return;
1028        }
1029        let load_op = clear_color.map_or(wgpu::LoadOp::Load, |c| {
1030            wgpu::LoadOp::Clear(wgpu::Color {
1031                r: f64::from(c.r),
1032                g: f64::from(c.g),
1033                b: f64::from(c.b),
1034                a: f64::from(c.a),
1035            })
1036        });
1037        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1038            label: None,
1039            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1040                view,
1041                resolve_target: None,
1042                depth_slice: None,
1043                ops: wgpu::Operations {
1044                    load: load_op,
1045                    store: wgpu::StoreOp::Store,
1046                },
1047            })],
1048            depth_stencil_attachment: None,
1049            occlusion_query_set: None,
1050            timestamp_writes: None,
1051            multiview_mask: None,
1052        });
1053        // Build all geometry once; then interleave geometry+text per z-layer so
1054        // that text belonging to low-z items cannot bleed over high-z panels.
1055        let ranges = self.build_batches(scene, pw, ph, queue);
1056        scene.texts.sort_by(|a, b| a.z.total_cmp(&b.z));
1057        let split = scene.texts.partition_point(|t| t.z < 1.0);
1058
1059        // Layer 0: normal widgets (z < 1.0)
1060        self.issue_draw_calls(&mut pass, pw, ph, &ranges, 0.0, 1.0, tex_reg);
1061        self.gpu_mut()
1062            .text
1063            .render(device, queue, &mut pass, &scene.texts[..split], pw, ph);
1064
1065        // Layer 1: overlays — popouts, dropdowns (z >= 1.0)
1066        // Uses a separate TextSystem so the second prepare() doesn't overwrite
1067        // the vertex buffer that layer 0's render commands still reference.
1068        self.issue_draw_calls(&mut pass, pw, ph, &ranges, 1.0, f32::MAX, tex_reg);
1069        self.gpu_mut()
1070            .text_overlay
1071            .render(device, queue, &mut pass, &scene.texts[split..], pw, ph);
1072    }
1073
1074    /// Present a frame (standalone mode). Acquires its own surface texture.
1075    pub(crate) fn present_standalone(
1076        &mut self,
1077        scene: &mut Scene,
1078        pw: f32,
1079        ph: f32,
1080        dt: f32,
1081        cursor: [f32; 3],
1082        clear_color: Option<crate::draw::Color>,
1083        tex_reg: &mut TextureRegistry,
1084    ) {
1085        let Some(sa) = &self.standalone else { return };
1086        // Advance time unconditionally so shader animations don't stall on empty frames.
1087        let queue = sa.queue.clone();
1088        self.advance_time(dt, pw, ph, cursor, &queue, tex_reg);
1089        if scene.is_empty() {
1090            return;
1091        }
1092
1093        let sa = self.standalone.as_ref().unwrap();
1094        let output = match sa.surface.get_current_texture() {
1095            wgpu::CurrentSurfaceTexture::Success(t)
1096            | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t,
1097            wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => {
1098                sa.surface.configure(&sa.device, &sa.config);
1099                return;
1100            }
1101            other => {
1102                eprintln!("[pane_ui] Surface error: {other:?}");
1103                return;
1104            }
1105        };
1106
1107        let view = output
1108            .texture
1109            .create_view(&wgpu::TextureViewDescriptor::default());
1110        let device = sa.device.clone();
1111        let queue = sa.queue.clone();
1112        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
1113        self.render(
1114            GpuFrame {
1115                encoder: &mut enc,
1116                view: &view,
1117                device: &device,
1118                queue: &queue,
1119            },
1120            scene,
1121            pw,
1122            ph,
1123            dt,
1124            cursor,
1125            clear_color,
1126            tex_reg,
1127        );
1128        queue.submit(std::iter::once(enc.finish()));
1129        output.present();
1130    }
1131
1132    /// Reconfigure the swapchain after a window resize (standalone mode only).
1133    fn resize(&mut self, w: u32, h: u32) {
1134        if let Some(sa) = &mut self.standalone {
1135            sa.config.width = w;
1136            sa.config.height = h;
1137            sa.surface.configure(&sa.device, &sa.config);
1138        }
1139    }
1140
1141    /// Upload all batch vertices to the GPU and return per-batch draw metadata.
1142    /// Call `issue_draw_calls` one or more times to actually draw subsets.
1143    fn build_batches(
1144        &self,
1145        scene: &mut Scene,
1146        pw: f32,
1147        ph: f32,
1148        queue: &wgpu::Queue,
1149    ) -> Vec<BatchRange> {
1150        if scene.batches.is_empty() {
1151            return Vec::new();
1152        }
1153        scene.batches.sort_by(|a, b| a.z.total_cmp(&b.z));
1154
1155        let mut all_verts: Vec<f32> = Vec::new();
1156        let mut ranges: Vec<BatchRange> = Vec::new();
1157
1158        for batch in &scene.batches {
1159            let start = u32::try_from(all_verts.len() / 16).unwrap_or(0);
1160
1161            let min_x = batch.verts.iter().map(|v| v[0]).fold(f32::MAX, f32::min);
1162            let max_x = batch.verts.iter().map(|v| v[0]).fold(f32::MIN, f32::max);
1163            let min_y = batch.verts.iter().map(|v| v[1]).fold(f32::MAX, f32::min);
1164            let max_y = batch.verts.iter().map(|v| v[1]).fold(f32::MIN, f32::max);
1165            let rw = (max_x - min_x).max(0.0001);
1166            let rh = (max_y - min_y).max(0.0001);
1167            let uv = batch.uv_rect.unwrap_or(UvRect::FULL);
1168            let unit = ph / 1080.0;
1169            let cr_px = (batch.corner_radius * unit).min(rw.min(rh) * unit * 0.5);
1170            let bw_px = batch.border_width * unit;
1171            let size_w = rw * unit;
1172            let size_h = rh * unit;
1173            let s = batch.state;
1174
1175            for v in &batch.verts {
1176                let [x, y] = to_ndc(v[0], v[1], pw, ph);
1177                let t_u = (v[0] - min_x) / rw;
1178                let t_v = (v[1] - min_y) / rh;
1179                let u = uv.u_min + t_u * (uv.u_max - uv.u_min);
1180                let vv = uv.v_min + t_v * (uv.v_max - uv.v_min);
1181                all_verts.extend_from_slice(&[
1182                    x,
1183                    y,
1184                    u,
1185                    vv,
1186                    batch.color.r,
1187                    batch.color.g,
1188                    batch.color.b,
1189                    batch.color.a,
1190                    cr_px,
1191                    bw_px,
1192                    size_w,
1193                    size_h,
1194                    s[0],
1195                    s[1],
1196                    s[2],
1197                    s[3],
1198                ]);
1199            }
1200            ranges.push((
1201                batch.shader,
1202                start..u32::try_from(all_verts.len() / 16).unwrap_or(0),
1203                batch.clip,
1204                batch.texture,
1205                batch.z,
1206            ));
1207        }
1208
1209        // Guard before writing — writing past the allocated buffer would be UB.
1210        if all_verts.len() > (MAX_VERTS * 16) as usize {
1211            eprintln!(
1212                "[pane] vertex buffer overflow: {} verts (max {}); frame skipped",
1213                all_verts.len() / 16,
1214                MAX_VERTS
1215            );
1216            return Vec::new();
1217        }
1218        queue.write_buffer(&self.gpu().vertex_buf, 0, bytemuck::cast_slice(&all_verts));
1219        ranges
1220    }
1221
1222    /// Issue draw calls for all batches whose z is in `[z_min, z_max)`.
1223    fn issue_draw_calls(
1224        &self,
1225        pass: &mut wgpu::RenderPass,
1226        pw: f32,
1227        ph: f32,
1228        ranges: &[BatchRange],
1229        z_min: f32,
1230        z_max: f32,
1231        tex_reg: &TextureRegistry,
1232    ) {
1233        if ranges
1234            .iter()
1235            .all(|(_, _, _, _, z)| *z < z_min || *z >= z_max)
1236        {
1237            return;
1238        }
1239        // Re-bind our vertex buffer — glyphon may have replaced it during the text pass.
1240        pass.set_vertex_buffer(0, self.gpu().vertex_buf.slice(..));
1241        pass.set_bind_group(1, &self.gpu().globals_bg, &[]);
1242        let mut current_clip: Option<ClipRect> = None;
1243        for (shader_id, range, clip, texture, z) in ranges {
1244            if *z < z_min || *z >= z_max {
1245                continue;
1246            }
1247            if *clip != current_clip {
1248                match clip {
1249                    Some(c) => {
1250                        let (l, t, r, b) = clip_to_pixels(*c, pw, ph);
1251                        let max_w = pw.max(0.0) as u32;
1252                        let max_h = ph.max(0.0) as u32;
1253                        let sx = (l.max(0.0) as u32).min(max_w);
1254                        let sy = (t.max(0.0) as u32).min(max_h);
1255                        let sw = ((r - l).max(0.0) as u32).min(max_w - sx);
1256                        let sh = ((b - t).max(0.0) as u32).min(max_h - sy);
1257                        if sw > 0 && sh > 0 {
1258                            pass.set_scissor_rect(sx, sy, sw, sh);
1259                        }
1260                    }
1261                    None => pass.set_scissor_rect(0, 0, pw.max(0.0) as u32, ph.max(0.0) as u32),
1262                }
1263                current_clip = *clip;
1264            }
1265            pass.set_pipeline(&self.pipelines[shader_id.index()]);
1266            if let Some(id) = texture
1267                && tex_reg.is_hidden(*id)
1268            {
1269                continue;
1270            }
1271            let bg = texture.map_or_else(|| tex_reg.dummy(), |id| tex_reg.current_bind_group(id));
1272            pass.set_bind_group(0, bg, &[]);
1273            pass.draw(range.clone(), 0..1);
1274        }
1275        pass.set_scissor_rect(0, 0, pw.max(0.0) as u32, ph.max(0.0) as u32);
1276    }
1277}
1278
1279// ── App (standalone event loop) ───────────────────────────────────────────────
1280
1281struct App<F: FnMut(&mut Pane, &mut Scene, &mut Input, f32, f32, f32)> {
1282    window: Option<Arc<Window>>,
1283    pane: Pane,
1284    scene: Scene,
1285    input: Input,
1286    draw_fn: F,
1287    last_frame: std::time::Instant,
1288}
1289
1290impl<F: FnMut(&mut Pane, &mut Scene, &mut Input, f32, f32, f32) + 'static> ApplicationHandler
1291    for App<F>
1292{
1293    fn resumed(&mut self, el: &ActiveEventLoop) {
1294        let win = Arc::new(
1295            el.create_window(Window::default_attributes().with_title("Pane"))
1296                .unwrap(),
1297        );
1298        if let Err(e) = pollster::block_on(self.pane.init(win.clone())) {
1299            eprintln!("{e}");
1300            el.exit();
1301            return;
1302        }
1303        self.window = Some(win);
1304    }
1305
1306    fn window_event(&mut self, el: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
1307        if let Some(win) = &self.window {
1308            let s = win.inner_size();
1309            self.input
1310                .handle_event(&event, s.width as f32, s.height as f32);
1311        }
1312        match event {
1313            WindowEvent::CloseRequested => el.exit(),
1314            WindowEvent::Resized(s) => self.pane.resize(s.width, s.height),
1315            WindowEvent::RedrawRequested => {
1316                if let Some(win) = &self.window {
1317                    let s = win.inner_size();
1318                    let pw = s.width as f32;
1319                    let ph = s.height as f32;
1320                    let now = std::time::Instant::now();
1321                    let dt = now.duration_since(self.last_frame).as_secs_f32().min(0.1);
1322                    self.last_frame = now;
1323                    self.scene.clear();
1324                    (self.draw_fn)(&mut self.pane, &mut self.scene, &mut self.input, pw, ph, dt);
1325                }
1326            }
1327            _ => {}
1328        }
1329    }
1330
1331    fn about_to_wait(&mut self, _: &ActiveEventLoop) {
1332        self.input.poll_gamepad();
1333        if let Some(win) = &self.window {
1334            win.request_redraw();
1335        }
1336    }
1337}
1338
1339// ── Entry Point ───────────────────────────────────────────────────────────────
1340
1341/// Run a standalone pane application, blocking until the window is closed.
1342///
1343/// `draw_fn` is called every frame with `(pane, scene, input, pw, ph, dt)` where
1344/// `pw`/`ph` are the window pixel dimensions and `dt` is the frame delta in seconds.
1345/// The closure is responsible for calling `run_frame` which drives
1346/// widget logic, fills the scene, and presents the frame.
1347///
1348/// # Panics
1349///
1350/// Panics if the event loop or window cannot be created.
1351pub fn run<F: FnMut(&mut Pane, &mut Scene, &mut Input, f32, f32, f32) + 'static>(draw_fn: F) {
1352    let el = EventLoop::new().unwrap();
1353    let mut app = App {
1354        window: None,
1355        pane: Pane::new_standalone(),
1356        scene: Scene::new(),
1357        input: Input::new_with_gilrs(),
1358        draw_fn,
1359        last_frame: std::time::Instant::now(),
1360    };
1361    el.run_app(&mut app).unwrap();
1362}