Skip to main content

urushi_graphics/
image.rs

1use std::fmt;
2use std::sync::Arc;
3
4use urushi::{
5    BlockStyle, Canvas, CanvasContext, CanvasItem, Key, Length, Position, ResolvedView, Size,
6    TextStyle, Theme, View,
7};
8use urushi_terminal::PixelSize as CellPixelSize;
9
10/// Pixel dimensions of a prepared raster.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct PixelSize {
13    width: u32,
14    height: u32,
15}
16
17/// Pixel coordinates within a prepared raster.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct PixelPosition {
20    x: u32,
21    y: u32,
22}
23
24impl PixelPosition {
25    pub const fn new(x: u32, y: u32) -> Self {
26        Self { x, y }
27    }
28
29    pub const fn x(self) -> u32 {
30        self.x
31    }
32
33    pub const fn y(self) -> u32 {
34        self.y
35    }
36}
37
38/// Terminal-cell dimensions requested for one image placement.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct CellSize {
41    width: u16,
42    height: u16,
43}
44
45impl CellSize {
46    pub const ZERO: Self = Self::new(0, 0);
47
48    pub const fn new(width: u16, height: u16) -> Self {
49        Self { width, height }
50    }
51
52    pub const fn width(self) -> u16 {
53        self.width
54    }
55
56    pub const fn height(self) -> u16 {
57        self.height
58    }
59}
60
61impl PixelSize {
62    pub const fn new(width: u32, height: u32) -> Self {
63        Self { width, height }
64    }
65
66    pub const fn width(self) -> u32 {
67        self.width
68    }
69
70    pub const fn height(self) -> u32 {
71        self.height
72    }
73}
74
75/// Why RGBA bytes cannot form a prepared raster.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum InvalidRgbaRaster {
78    /// At least one pixel dimension is zero.
79    Empty,
80    /// The byte length does not equal `width * height * 4`.
81    ByteLength { expected: usize, actual: usize },
82    /// The expected byte length does not fit in `usize`.
83    ByteLengthOverflow,
84}
85
86impl fmt::Display for InvalidRgbaRaster {
87    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88        match self {
89            Self::Empty => formatter.write_str("an RGBA raster must have non-zero dimensions"),
90            Self::ByteLength { expected, actual } => write!(
91                formatter,
92                "an RGBA raster of this size needs {expected} bytes, but received {actual}"
93            ),
94            Self::ByteLengthOverflow => {
95                formatter.write_str("the RGBA raster byte length does not fit in memory")
96            }
97        }
98    }
99}
100
101impl std::error::Error for InvalidRgbaRaster {}
102
103/// Immutable, prepared RGBA pixels identified independently from a placement.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RgbaRaster {
106    key: Key,
107    size: PixelSize,
108    bytes: Arc<[u8]>,
109}
110
111impl RgbaRaster {
112    fn new(
113        key: impl Into<Key>,
114        size: PixelSize,
115        bytes: impl Into<Arc<[u8]>>,
116    ) -> Result<Self, InvalidRgbaRaster> {
117        if size.width == 0 || size.height == 0 {
118            return Err(InvalidRgbaRaster::Empty);
119        }
120        let expected = usize::try_from(size.width)
121            .ok()
122            .and_then(|width| {
123                usize::try_from(size.height)
124                    .ok()
125                    .and_then(|height| width.checked_mul(height))
126            })
127            .and_then(|pixels| pixels.checked_mul(4))
128            .ok_or(InvalidRgbaRaster::ByteLengthOverflow)?;
129        let bytes = bytes.into();
130        if bytes.len() != expected {
131            return Err(InvalidRgbaRaster::ByteLength {
132                expected,
133                actual: bytes.len(),
134            });
135        }
136        Ok(Self {
137            key: key.into(),
138            size,
139            bytes,
140        })
141    }
142
143    /// Returns the stable identity of the prepared pixel content.
144    pub const fn key(&self) -> Key {
145        self.key
146    }
147
148    pub const fn size(&self) -> PixelSize {
149        self.size
150    }
151
152    pub fn bytes(&self) -> &[u8] {
153        &self.bytes
154    }
155}
156
157/// Prepared image content and the identities of its asset and placement.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Image {
160    key: Key,
161    raster: RgbaRaster,
162    fallback: String,
163}
164
165impl Image {
166    /// Creates an image from checked, prepared RGBA pixels.
167    pub fn rgba(
168        key: impl Into<Key>,
169        asset_key: impl Into<Key>,
170        pixels: PixelSize,
171        rgba: impl Into<Arc<[u8]>>,
172    ) -> Result<Self, InvalidRgbaRaster> {
173        Ok(Self {
174            key: key.into(),
175            raster: RgbaRaster::new(asset_key, pixels, rgba)?,
176            fallback: "[image]".to_owned(),
177        })
178    }
179
180    /// Replaces the text shown when no graphics adapter is used.
181    #[must_use]
182    pub fn fallback(mut self, fallback: impl Into<String>) -> Self {
183        self.fallback = fallback.into();
184        self
185    }
186
187    pub const fn key(&self) -> Key {
188        self.key
189    }
190
191    pub const fn raster(&self) -> &RgbaRaster {
192        &self.raster
193    }
194
195    pub fn get_fallback(&self) -> &str {
196        &self.fallback
197    }
198
199    /// Locates this image in an already resolved Urushi view.
200    pub fn placement<'a>(
201        &'a self,
202        view: &ResolvedView,
203        cell_pixels: Option<CellPixelSize>,
204    ) -> Option<GraphicPlacement<'a>> {
205        let region = view.anchor(self.key)?;
206        let visible = region.visible();
207        Some(GraphicPlacement {
208            key: self.key,
209            raster: &self.raster,
210            logical_origin: Position::new(region.x(), region.y()),
211            logical_size: Size::new(region.width(), region.height()),
212            visible_origin: visible.map(|visible| Position::new(visible.x(), visible.y())),
213            visible_size: visible.map(|visible| Size::new(visible.width(), visible.height())),
214            cell_pixels,
215        })
216    }
217}
218
219/// Canonical policy for composing an [`Image`] into a fixed cell rectangle.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ImagePresentation {
222    fallback_style: TextStyle,
223}
224
225impl Default for ImagePresentation {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231impl ImagePresentation {
232    /// Creates an image presentation with the default fallback text style.
233    pub fn new() -> Self {
234        Self {
235            fallback_style: TextStyle::new(),
236        }
237    }
238
239    /// Derives the canonical image fallback style from an Urushi theme.
240    pub fn from_theme(theme: &Theme) -> Self {
241        Self::new().fallback_style(
242            TextStyle::new()
243                .foreground(theme.tokens().text_muted)
244                .italic(),
245        )
246    }
247
248    pub const fn get_fallback_style(&self) -> &TextStyle {
249        &self.fallback_style
250    }
251
252    #[must_use]
253    pub fn fallback_style(mut self, style: TextStyle) -> Self {
254        self.fallback_style = style;
255        self
256    }
257
258    /// Composes `image` as a generic anchored region with fallback cells.
259    pub fn compose(&self, image: &Image, cells: CellSize) -> View {
260        let size = Size::new(usize::from(cells.width()), usize::from(cells.height()));
261        let canvas = Canvas::new().extent(size).item(ImageCanvasItem {
262            image: image.clone(),
263            cells,
264            fallback_style: self.fallback_style.clone(),
265        });
266        View::block(
267            BlockStyle::new()
268                .width(Length::Cells(cells.width()))
269                .height(Length::Cells(cells.height())),
270            View::canvas(canvas),
271        )
272    }
273}
274
275#[derive(Debug, Clone, PartialEq)]
276struct ImageCanvasItem {
277    image: Image,
278    cells: CellSize,
279    fallback_style: TextStyle,
280}
281
282impl CanvasItem for ImageCanvasItem {
283    fn draw(&self, context: &mut CanvasContext) {
284        let width = usize::from(self.cells.width());
285        let height = usize::from(self.cells.height());
286        context.view(
287            Position::default(),
288            View::anchor_block(
289                self.image.key,
290                BlockStyle::new()
291                    .width(Length::Cells(self.cells.width()))
292                    .height(Length::Cells(self.cells.height())),
293                View::empty(),
294            ),
295            Some(width),
296            Some(height),
297        );
298        context.text(
299            Position::default(),
300            self.image.fallback.clone(),
301            self.fallback_style.clone(),
302        );
303    }
304}
305
306pub(crate) fn collect(view: &View) -> Vec<&Image> {
307    let mut images = Vec::new();
308    collect_from(view, &mut images);
309    images
310}
311
312/// Returns the image placements embedded in `view` after layout resolution.
313///
314/// Interactive hosts use these protocol-neutral rectangles to coordinate the
315/// cell layer with a selected graphics lifecycle. An image whose anchor is not
316/// present in `resolved` is omitted; a completely clipped image remains a
317/// placement with no visible rectangle.
318pub fn placements<'a>(
319    view: &'a View,
320    resolved: &ResolvedView,
321    cell_pixels: Option<CellPixelSize>,
322) -> Vec<GraphicPlacement<'a>> {
323    collect(view)
324        .into_iter()
325        .filter_map(|image| image.placement(resolved, cell_pixels))
326        .collect()
327}
328
329fn collect_from<'a>(view: &'a View, images: &mut Vec<&'a Image>) {
330    match view {
331        View::Text(_) => {}
332        View::Block(_, _, child) | View::Viewport(_, child) | View::AnchorBlock(_, _, _, child) => {
333            collect_from(child, images);
334        }
335        View::Row(_, children) | View::Column(_, children) => {
336            for child in children {
337                collect_from(child, images);
338            }
339        }
340        View::Grid(_, rows) => {
341            for child in rows.iter().flatten() {
342                collect_from(child, images);
343            }
344        }
345        View::Canvas(canvas) => {
346            images.extend(canvas.items::<ImageCanvasItem>().map(|item| &item.image));
347        }
348    }
349}
350
351/// One renderer-neutral image placement derived from an Urushi anchor.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub struct GraphicPlacement<'a> {
354    key: Key,
355    raster: &'a RgbaRaster,
356    logical_origin: Position,
357    logical_size: Size,
358    visible_origin: Option<Position>,
359    visible_size: Option<Size>,
360    cell_pixels: Option<CellPixelSize>,
361}
362
363impl<'a> GraphicPlacement<'a> {
364    pub const fn key(&self) -> Key {
365        self.key
366    }
367
368    pub const fn raster(&self) -> &'a RgbaRaster {
369        self.raster
370    }
371
372    /// Returns the unclipped rectangle's origin in resolved-view cells.
373    pub const fn logical_origin(&self) -> Position {
374        self.logical_origin
375    }
376
377    /// Returns the unclipped rectangle's size in cells.
378    pub const fn logical_size(&self) -> Size {
379        self.logical_size
380    }
381
382    /// Returns the origin of the rectangle that survived every enclosing clip.
383    pub const fn visible_origin(&self) -> Option<Position> {
384        self.visible_origin
385    }
386
387    /// Returns the size of the rectangle that survived every enclosing clip.
388    pub const fn visible_size(&self) -> Option<Size> {
389        self.visible_size
390    }
391
392    /// Returns the observed dimensions of one terminal cell, when available.
393    pub const fn cell_pixels(&self) -> Option<CellPixelSize> {
394        self.cell_pixels
395    }
396
397    /// Returns the visible rectangle's offset within the source raster.
398    pub fn source_offset(&self) -> Option<PixelPosition> {
399        let (x, y, _, _) = self.source_bounds()?;
400        Some(PixelPosition::new(x, y))
401    }
402
403    /// Returns the visible rectangle's extent within the source raster.
404    pub fn source_size(&self) -> Option<PixelSize> {
405        let (left, top, right, bottom) = self.source_bounds()?;
406        Some(PixelSize::new(right - left, bottom - top))
407    }
408
409    /// Returns the visible output extent in terminal pixels.
410    pub fn visible_pixels(&self) -> Option<CellPixelSize> {
411        let visible = self.visible_size?;
412        let cell = self.cell_pixels?;
413        Some(CellPixelSize::new(
414            visible.width().checked_mul(cell.width())?,
415            visible.height().checked_mul(cell.height())?,
416        ))
417    }
418
419    fn source_bounds(&self) -> Option<(u32, u32, u32, u32)> {
420        let visible_origin = self.visible_origin?;
421        let visible_size = self.visible_size?;
422        if self.logical_size.is_empty() || visible_size.is_empty() {
423            return None;
424        }
425        let offset_x = u128::try_from(visible_origin.x.checked_sub(self.logical_origin.x)?).ok()?;
426        let offset_y = u128::try_from(visible_origin.y.checked_sub(self.logical_origin.y)?).ok()?;
427        let logical_width = self.logical_size.width() as u128;
428        let logical_height = self.logical_size.height() as u128;
429        let raster_width = u128::from(self.raster.size().width());
430        let raster_height = u128::from(self.raster.size().height());
431        let visible_right = offset_x.checked_add(visible_size.width() as u128)?;
432        let visible_bottom = offset_y.checked_add(visible_size.height() as u128)?;
433        let left = raster_width.checked_mul(offset_x)? / logical_width;
434        let top = raster_height.checked_mul(offset_y)? / logical_height;
435        let right = raster_width
436            .checked_mul(visible_right)?
437            .div_ceil(logical_width)
438            .min(raster_width);
439        let bottom = raster_height
440            .checked_mul(visible_bottom)?
441            .div_ceil(logical_height)
442            .min(raster_height);
443        Some((
444            left.try_into().ok()?,
445            top.try_into().ok()?,
446            right.try_into().ok()?,
447            bottom.try_into().ok()?,
448        ))
449    }
450}