Skip to main content

rlx_core/render/
panel.rs

1//! Panels: the translucent backdrops the interface's text sits on, so a list
2//! stays readable over a bright scene.
3//!
4//! A panel is a rounded rectangle in the theme's `panel` fill, with a 1 px lit
5//! edge in `panel_edge` and, optionally, a faint scanline modulation every
6//! `scanline_pitch`th row — every value from [`THEME`](super::theme::THEME), so a
7//! panel carries no colour of its own. The same pass draws the row highlight
8//! ([`PanelKind::Highlight`]), which is why the selection can glide under text
9//! without a second pipeline.
10//!
11//! **All of a frame's panels are one instanced draw**, recorded by the text
12//! layer before its glyphs in the same pass. The rounded corners, the edge and
13//! the scanlines are computed per fragment from the panel's rectangle, so there
14//! is no texture and no bind group.
15
16// Hot-path panic-denial pragma (`render/` scan set): the pass is prepared every
17// frame a panel is queued.
18#![deny(
19    clippy::unwrap_used,
20    clippy::expect_used,
21    clippy::indexing_slicing,
22    clippy::panic,
23    clippy::unreachable
24)]
25
26/// One panel, in device pixels from the target's top-left — the coordinate
27/// space a [`TextRun`](super::TextRun) uses.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct Panel {
30    /// Left edge.
31    pub x: f32,
32    /// Top edge.
33    pub y: f32,
34    /// Width.
35    pub w: f32,
36    /// Height.
37    pub h: f32,
38    /// Opacity multiplier over the theme's own, `0.0..=1.0`: an envelope fading
39    /// the panel with the text it holds.
40    pub alpha: f32,
41    /// Whether the scanline modulation is drawn. A highlight never carries one.
42    pub scanlines: bool,
43    /// What the rectangle is.
44    pub kind: PanelKind,
45}
46
47/// The two things a panel rectangle can be.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum PanelKind {
50    /// A backdrop: the theme's `panel` fill, a 1 px `panel_edge`, rounded
51    /// corners, and the scanline when asked for.
52    #[default]
53    Backdrop,
54    /// The selection marker on a row: the theme's `highlight` fill with a 4 px
55    /// `accent` bar at its left edge, square-cornered, no edge.
56    Highlight,
57}
58
59/// Width of a highlight's accent bar, device pixels.
60pub const HIGHLIGHT_BAR_W: f32 = 4.0;
61
62impl Panel {
63    /// A fully opaque (as the theme declares it) backdrop with scanlines.
64    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
65        Self {
66            x,
67            y,
68            w,
69            h,
70            alpha: 1.0,
71            scanlines: true,
72            kind: PanelKind::Backdrop,
73        }
74    }
75
76    /// A row highlight at `(x, y)`, `w` x `h`.
77    pub fn highlight(x: f32, y: f32, w: f32, h: f32) -> Self {
78        Self {
79            scanlines: false,
80            kind: PanelKind::Highlight,
81            ..Self::new(x, y, w, h)
82        }
83    }
84
85    /// The panel that holds a block of text spanning `x0..x1` horizontally and
86    /// `y0..y1` vertically, with `pad` pixels around it on every side.
87    pub fn around(x0: f32, y0: f32, x1: f32, y1: f32, pad: f32) -> Self {
88        Self::new(
89            x0 - pad,
90            y0 - pad,
91            (x1 - x0) + 2.0 * pad,
92            (y1 - y0) + 2.0 * pad,
93        )
94    }
95
96    /// The same panel with every coordinate multiplied by `s` — for a surface
97    /// that scales its text by the same factor.
98    pub fn scaled(self, s: f32) -> Self {
99        Self {
100            x: self.x * s,
101            y: self.y * s,
102            w: self.w * s,
103            h: self.h * s,
104            ..self
105        }
106    }
107}
108
109#[cfg(feature = "text")]
110pub(crate) use pass::PanelPass;
111
112#[cfg(feature = "text")]
113mod pass {
114    use super::{HIGHLIGHT_BAR_W, Panel, PanelKind};
115    use crate::render::theme::THEME;
116
117    /// Panels one frame draws; more are dropped. The interface draws a handful.
118    const MAX_PANELS: usize = 32;
119
120    /// One panel as the shader reads it.
121    #[repr(C)]
122    #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
123    struct Instance {
124        /// `(x, y, w, h)` in device pixels.
125        rect: [f32; 4],
126        /// The fill, linear, with the panel's alpha applied.
127        fill: [f32; 4],
128        /// The lit edge, linear, with the panel's alpha applied.
129        edge: [f32; 4],
130        /// The accent bar at the left edge, linear, with the panel's alpha
131        /// applied; transparent on a backdrop.
132        bar: [f32; 4],
133        /// `(radius px, scanline alpha, scanline pitch px, target height px)`.
134        params: [f32; 4],
135        /// `(target width px, bar width px, unused, unused)`.
136        extra: [f32; 4],
137    }
138
139    /// The rectangle becomes NDC in the vertex stage, from the target size in
140    /// `params.w` and `extra.x`; the fragment stage works in framebuffer pixels
141    /// (`@builtin(position)`, top-left origin, pixel centres at `.5`), where the
142    /// rounded-box distance is exact.
143    const SHADER: &str = r#"
144struct VsOut {
145    @builtin(position) pos: vec4<f32>,
146    @location(0) rect: vec4<f32>,
147    @location(1) fill: vec4<f32>,
148    @location(2) edge: vec4<f32>,
149    @location(3) bar: vec4<f32>,
150    @location(4) params: vec4<f32>,
151    @location(5) extra: vec4<f32>,
152};
153
154@vertex
155fn vs_main(
156    @builtin(vertex_index) vi: u32,
157    @location(0) rect: vec4<f32>,
158    @location(1) fill: vec4<f32>,
159    @location(2) edge: vec4<f32>,
160    @location(3) bar: vec4<f32>,
161    @location(4) params: vec4<f32>,
162    @location(5) extra: vec4<f32>,
163) -> VsOut {
164    var corners = array<vec2<f32>, 6>(
165        vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
166        vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
167    );
168    let px = rect.xy + corners[vi] * rect.zw;
169    let size = vec2<f32>(extra.x, params.w);
170    var out: VsOut;
171    out.pos = vec4<f32>(px.x / size.x * 2.0 - 1.0, 1.0 - px.y / size.y * 2.0, 0.0, 1.0);
172    out.rect = rect;
173    out.fill = fill;
174    out.edge = edge;
175    out.bar = bar;
176    out.params = params;
177    out.extra = extra;
178    return out;
179}
180
181@fragment
182fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
183    let p = in.pos.xy;
184    let half = in.rect.zw * 0.5;
185    let r = min(in.params.x, min(half.x, half.y));
186    let q = abs(p - (in.rect.xy + half)) - half + vec2<f32>(r, r);
187    // Signed distance to the rounded box: negative inside.
188    let d = length(max(q, vec2<f32>(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - r;
189    let coverage = clamp(0.5 - d, 0.0, 1.0);
190    if (coverage <= 0.0) {
191        discard;
192    }
193
194    var fill = in.fill;
195    // Every pitch-th row from the panel's top carries the scanline.
196    let pitch = max(in.params.z, 1.0);
197    let row = floor(p.y - in.rect.y);
198    if (in.params.y > 0.0 && row - pitch * floor(row / pitch) < 0.5) {
199        fill = vec4<f32>(fill.rgb * (1.0 - in.params.y), fill.a + in.params.y * (1.0 - fill.a));
200    }
201
202    // The 1 px lit edge: full for `d > -0.5`, gone by `d = -1.5`, so a one-pixel
203    // band inside the outline, antialiased on its inner side (its outer side is
204    // `coverage`'s).
205    var edge_amt = clamp(d + 1.5, 0.0, 1.0) * in.edge.a;
206    var edge_rgb = in.edge.rgb;
207    // The accent bar replaces the edge along the left `extra.y` pixels.
208    if (p.x - in.rect.x < in.extra.y) {
209        edge_amt = in.bar.a;
210        edge_rgb = in.bar.rgb;
211    }
212    let rgb = edge_rgb * edge_amt + fill.rgb * fill.a * (1.0 - edge_amt);
213    let a = edge_amt + fill.a * (1.0 - edge_amt);
214    return vec4<f32>(rgb, a) * coverage;
215}
216"#;
217
218    /// The panels' pipeline and instance buffer. Built with the text layer that
219    /// records it, against the same target format.
220    pub(crate) struct PanelPass {
221        pipeline: wgpu::RenderPipeline,
222        instances: wgpu::Buffer,
223        scratch: Vec<Instance>,
224        /// Panels written by the last [`prepare`](Self::prepare).
225        count: u32,
226    }
227
228    impl PanelPass {
229        pub(crate) fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
230            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
231                label: Some("rlx-panel-shader"),
232                source: wgpu::ShaderSource::Wgsl(SHADER.into()),
233            });
234            let instances = device.create_buffer(&wgpu::BufferDescriptor {
235                label: Some("rlx-panel-instances"),
236                size: (MAX_PANELS * std::mem::size_of::<Instance>()) as u64,
237                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
238                mapped_at_creation: false,
239            });
240            let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
241                label: Some("rlx-panel-pipeline-layout"),
242                bind_group_layouts: &[],
243                immediate_size: 0,
244            });
245            let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
246                label: Some("rlx-panel-pipeline"),
247                layout: Some(&layout),
248                vertex: wgpu::VertexState {
249                    module: &shader,
250                    entry_point: Some("vs_main"),
251                    compilation_options: Default::default(),
252                    buffers: &[Some(wgpu::VertexBufferLayout {
253                        array_stride: std::mem::size_of::<Instance>() as u64,
254                        step_mode: wgpu::VertexStepMode::Instance,
255                        attributes: &wgpu::vertex_attr_array![
256                            0 => Float32x4,
257                            1 => Float32x4,
258                            2 => Float32x4,
259                            3 => Float32x4,
260                            4 => Float32x4,
261                            5 => Float32x4,
262                        ],
263                    })],
264                },
265                fragment: Some(wgpu::FragmentState {
266                    module: &shader,
267                    entry_point: Some("fs_main"),
268                    compilation_options: Default::default(),
269                    targets: &[Some(wgpu::ColorTargetState {
270                        format,
271                        // The fragment stage outputs premultiplied colour.
272                        blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
273                        write_mask: wgpu::ColorWrites::ALL,
274                    })],
275                }),
276                primitive: wgpu::PrimitiveState::default(),
277                depth_stencil: None,
278                multisample: wgpu::MultisampleState::default(),
279                multiview_mask: None,
280                cache: None,
281            });
282            Self {
283                pipeline,
284                instances,
285                scratch: Vec::with_capacity(MAX_PANELS),
286                count: 0,
287            }
288        }
289
290        /// Write `panels` for a `width`x`height` target. Returns whether
291        /// [`render`](Self::render) will draw. A panel with no area, or with a
292        /// coordinate that is not finite, is skipped.
293        pub(crate) fn prepare(
294            &mut self,
295            queue: &wgpu::Queue,
296            panels: &[Panel],
297            width: u32,
298            height: u32,
299        ) -> bool {
300            self.scratch.clear();
301            // Resolved per prepare: the sRGB decode is not a `const fn`.
302            let backdrop = (THEME.panel.linear(), THEME.panel_edge.linear());
303            let highlight = THEME.highlight.linear();
304            let accent = THEME.accent.linear();
305            let scan = THEME.scanline.a;
306            let with_alpha = |[r, g, b, a]: [f32; 4], k: f32| [r, g, b, a * k];
307            for panel in panels.iter().take(MAX_PANELS) {
308                let finite = [panel.x, panel.y, panel.w, panel.h, panel.alpha]
309                    .iter()
310                    .all(|v| v.is_finite());
311                if !finite || panel.w <= 0.0 || panel.h <= 0.0 || panel.alpha <= 0.0 {
312                    continue;
313                }
314                let alpha = panel.alpha.min(1.0);
315                let (fill, edge, bar, radius, bar_w) = match panel.kind {
316                    PanelKind::Backdrop => (backdrop.0, backdrop.1, [0.0; 4], THEME.radius, 0.0),
317                    PanelKind::Highlight => (highlight, [0.0; 4], accent, 0.0, HIGHLIGHT_BAR_W),
318                };
319                let scanlines = panel.scanlines && panel.kind == PanelKind::Backdrop;
320                self.scratch.push(Instance {
321                    rect: [panel.x, panel.y, panel.w, panel.h],
322                    fill: with_alpha(fill, alpha),
323                    edge: with_alpha(edge, alpha),
324                    bar: with_alpha(bar, alpha),
325                    params: [
326                        radius,
327                        if scanlines { scan * alpha } else { 0.0 },
328                        THEME.scanline_pitch as f32,
329                        height.max(1) as f32,
330                    ],
331                    extra: [width.max(1) as f32, bar_w, 0.0, 0.0],
332                });
333            }
334            self.count = self.scratch.len() as u32;
335            if self.count == 0 {
336                return false;
337            }
338            queue.write_buffer(&self.instances, 0, bytemuck::cast_slice(&self.scratch));
339            true
340        }
341
342        /// Draw the prepared panels into `pass`, as one instanced draw.
343        pub(crate) fn render(&self, pass: &mut wgpu::RenderPass<'_>) {
344            if self.count == 0 {
345                return;
346            }
347            pass.set_pipeline(&self.pipeline);
348            pass.set_vertex_buffer(0, self.instances.slice(..));
349            pass.draw(0..6, 0..self.count);
350        }
351
352        /// Forget the last prepare, so a frame with no panels draws none.
353        pub(crate) fn end_frame(&mut self) {
354            self.count = 0;
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn a_panel_around_a_block_pads_every_side() {
365        let p = Panel::around(10.0, 20.0, 110.0, 70.0, 8.0);
366        assert_eq!((p.x, p.y, p.w, p.h), (2.0, 12.0, 116.0, 66.0));
367        assert_eq!(p.alpha, 1.0);
368    }
369
370    #[test]
371    fn scaling_a_panel_scales_its_rectangle_and_nothing_else() {
372        let p = Panel {
373            alpha: 0.5,
374            scanlines: false,
375            ..Panel::new(10.0, 20.0, 30.0, 40.0)
376        }
377        .scaled(0.5);
378        assert_eq!((p.x, p.y, p.w, p.h), (5.0, 10.0, 15.0, 20.0));
379        assert_eq!(p.alpha, 0.5);
380        assert!(!p.scanlines);
381    }
382}