feat(renderer): port the generic renderer, renderer thread and state
Ports renderer/{generic,cell,row,link,State,Overlay,image,Thread}.zig:
- generic.rs builds GPU cell buffers from terminal RenderState each
frame: backgrounds, shaped text runs with glyph atlases and nerd font
constraints, all cursor styles, decorations via sprites, selection and
search colors, min-contrast, bold/faint/blink handling, preedit,
padding extension, background image, custom shader uniforms, link
hover underlines, scrollbar and health reporting.
- The renderer thread updates CPU-side state only and asks the host to
redraw; the app runtime calls draw_frame from the GLArea render signal
(the GL context lives on the GTK thread, as in Ghostty).
- ghostty_renderer::state::{State, SharedState} is the canonical shared
renderer state (termio's interim copy will switch to it).
Deviations: overlay uses a direct RGBA rasterizer instead of z2d; bg
images decode with png/jpeg-decoder instead of wuffs; zero-length link
matches advance by one char (Zig would loop forever).
Kitty image rendering is implemented in image::State but not yet called
from generic::update_kitty (raced with the terminal-side wiring).
Test Plan:
- cargo test -p ghostty-renderer (69 unit + 9 headless EGL tests that
render a Terminal through the full pipeline and check pixels)
This commit is contained in:
1 parent
25da94fb75
commit
2fbb43317c
16 files changed
+7491
-128
No files matched your search
@@ -0,0 +1,1162 @@
|
||||
//! Generic image rendering state for the renderer (`renderer/image.zig`).
|
||||
//!
|
||||
//! This stores all images and their placements and exposes only a limited
|
||||
//! public API for adding images and placements and drawing them. Image data
|
||||
//! is converted to RGBA on the CPU (`prep_for_upload`, safe to do without a
|
||||
//! GL context) and uploaded to the GPU on the thread that owns the context
|
||||
//! (`State::upload`, called from `draw_frame`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use ghostty_terminal::kitty::graphics_command::TransmissionFormat;
|
||||
use ghostty_terminal::kitty::graphics_storage::{ImageStorage, TerminalInfo};
|
||||
use ghostty_terminal::kitty::{Image as KittyImage, unicode};
|
||||
use ghostty_terminal::page_list::PageList;
|
||||
use ghostty_terminal::point::Tag;
|
||||
use ghostty_terminal::terminal::Terminal;
|
||||
|
||||
use crate::opengl::gl::{Gl, Primitive};
|
||||
use crate::opengl::pipeline::Pipeline;
|
||||
use crate::opengl::render_pass::{Draw, RenderPass, Step};
|
||||
use crate::opengl::shaders::Image as ImageVertex;
|
||||
use crate::opengl::{Buffer, Texture, buffer, texture};
|
||||
use crate::overlay::Overlay;
|
||||
use crate::size::CellSize;
|
||||
|
||||
/// The GPU objects needed to upload and draw images. Built per frame from
|
||||
/// the graphics API (the API itself is mutably borrowed by the frame while
|
||||
/// drawing).
|
||||
pub struct Gpu<'a> {
|
||||
pub gl: &'a Gl,
|
||||
/// `api.imageTextureOptions(.rgba, true)`.
|
||||
pub texture_options: texture::Options,
|
||||
/// `api.imageBufferOptions()`.
|
||||
pub buffer_options: buffer::Options,
|
||||
}
|
||||
|
||||
/// Generic image rendering state for the renderer. This stores all
|
||||
/// images and their placements and exposes only a limited public API
|
||||
/// for adding images and placements and drawing them.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// The full image state for the renderer that specifies what images
|
||||
/// need to be uploaded, pruned, etc.
|
||||
pub images: HashMap<Id, Entry>,
|
||||
|
||||
/// The placements for the Kitty image protocol.
|
||||
pub kitty_placements: Vec<Placement>,
|
||||
|
||||
/// The end index (exclusive) for placements that should be
|
||||
/// drawn below the background, below the text, etc.
|
||||
pub kitty_bg_end: u32,
|
||||
pub kitty_text_end: u32,
|
||||
|
||||
/// True if there are any virtual placements. This needs to be known
|
||||
/// because virtual placements need to be recalculated more often
|
||||
/// on frame builds and are generally more expensive to handle.
|
||||
pub kitty_virtual: bool,
|
||||
|
||||
/// Overlays
|
||||
pub overlay_placements: Vec<Placement>,
|
||||
|
||||
/// Forces the next [`State::kitty_requires_update`] to return true. Set
|
||||
/// when the GPU context was recreated and the textures must be rebuilt.
|
||||
pub force_kitty_update: bool,
|
||||
}
|
||||
|
||||
/// An image in the map plus the time it was transmitted.
|
||||
pub struct Entry {
|
||||
pub image: Image,
|
||||
pub transmit_time: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrawPlacements {
|
||||
KittyBelowBg,
|
||||
KittyBelowText,
|
||||
KittyAboveText,
|
||||
Overlay,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PrepImageError {
|
||||
#[error("image conversion failed")]
|
||||
ImageConversionError,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new() -> State {
|
||||
State::default()
|
||||
}
|
||||
|
||||
/// Upload any images to the GPU that need to be uploaded,
|
||||
/// and remove any images that are no longer needed on the GPU.
|
||||
///
|
||||
/// If any uploads fail, they are ignored. The return value
|
||||
/// can be used to detect if upload was a total success (true)
|
||||
/// or not (false).
|
||||
pub fn upload(&mut self, gpu: &Gpu<'_>) -> bool {
|
||||
let mut success = true;
|
||||
self.images.retain(|_, entry| {
|
||||
let img = &mut entry.image;
|
||||
if img.is_unloading() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if img.is_pending()
|
||||
&& let Err(err) = img.upload(gpu)
|
||||
{
|
||||
log::warn!("error uploading image to GPU err={err}");
|
||||
success = false;
|
||||
}
|
||||
true
|
||||
});
|
||||
|
||||
success
|
||||
}
|
||||
|
||||
/// Draw the given named set of placements.
|
||||
///
|
||||
/// Any placements that have non-uploaded images are ignored. Any
|
||||
/// graphics API errors during drawing are also ignored.
|
||||
pub fn draw(
|
||||
&self,
|
||||
gpu: &Gpu<'_>,
|
||||
pipeline: &Pipeline,
|
||||
pass: &mut RenderPass<'_>,
|
||||
placement_type: DrawPlacements,
|
||||
) {
|
||||
let bg_end = self.kitty_bg_end as usize;
|
||||
let text_end = self.kitty_text_end as usize;
|
||||
let placements: &[Placement] = match placement_type {
|
||||
DrawPlacements::KittyBelowBg => &self.kitty_placements[..bg_end],
|
||||
DrawPlacements::KittyBelowText => &self.kitty_placements[bg_end..text_end],
|
||||
DrawPlacements::KittyAboveText => &self.kitty_placements[text_end..],
|
||||
DrawPlacements::Overlay => &self.overlay_placements,
|
||||
};
|
||||
|
||||
for p in placements {
|
||||
// Look up the image
|
||||
let Some(entry) = self.images.get(&p.image_id) else {
|
||||
log::warn!("image not found for placement image_id={:?}", p.image_id);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Get the texture
|
||||
let Some(texture) = entry.image.ready_texture() else {
|
||||
log::warn!("image not ready for placement image_id={:?}", p.image_id);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Create our vertex buffer, which is always exactly one item.
|
||||
// future(mitchellh): we can group rendering multiple instances of a single image
|
||||
let buf = match Buffer::<ImageVertex>::init_fill(
|
||||
gpu.gl,
|
||||
gpu.buffer_options,
|
||||
&[ImageVertex {
|
||||
grid_pos: [p.x as f32, p.y as f32],
|
||||
cell_offset: [p.cell_offset_x as f32, p.cell_offset_y as f32],
|
||||
source_rect: [
|
||||
p.source_x as f32,
|
||||
p.source_y as f32,
|
||||
p.source_width as f32,
|
||||
p.source_height as f32,
|
||||
],
|
||||
dest_size: [p.width as f32, p.height as f32],
|
||||
pad: [0.0; 2],
|
||||
}],
|
||||
) {
|
||||
Ok(b) => b,
|
||||
Err(err) => {
|
||||
log::warn!("error creating image vertex buffer err={err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
pass.step(&Step {
|
||||
pipeline,
|
||||
uniforms: None,
|
||||
buffers: &[Some(buf.raw())],
|
||||
textures: &[Some(texture)],
|
||||
samplers: &[],
|
||||
draw: Draw::new(Primitive::TriangleStrip, 4),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update our overlay state. None deletes any existing overlay.
|
||||
pub fn overlay_update(&mut self, overlay: Option<&Overlay>) -> Result<(), PrepImageError> {
|
||||
let Some(overlay) = overlay else {
|
||||
// If we don't have an overlay, remove any existing one.
|
||||
if let Some(data) = self.images.get_mut(&Id::Overlay) {
|
||||
data.image.mark_for_unload();
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// For transmit time we always just use the current time
|
||||
// and overwrite the overlay.
|
||||
let transmit_time = Instant::now();
|
||||
|
||||
// Setup our image.
|
||||
let pending = overlay.pending_image();
|
||||
let (width, height) = (pending.width, pending.height);
|
||||
self.overlay_placements.clear();
|
||||
self.prep_image(Id::Overlay, transmit_time, pending)?;
|
||||
|
||||
// Setup our placement
|
||||
self.overlay_placements.push(Placement {
|
||||
image_id: Id::Overlay,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
width,
|
||||
height,
|
||||
cell_offset_x: 0,
|
||||
cell_offset_y: 0,
|
||||
source_x: 0,
|
||||
source_y: 0,
|
||||
source_width: width,
|
||||
source_height: height,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if the Kitty graphics state requires an update based
|
||||
/// on the terminal state and our internal state.
|
||||
///
|
||||
/// This does not read/write state used by drawing.
|
||||
pub fn kitty_requires_update(&self, t: &Terminal) -> bool {
|
||||
// If the terminal kitty image state is dirty, we must update.
|
||||
if self.force_kitty_update || t.screen().kitty_images.dirty {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we have any virtual references, we must also rebuild our
|
||||
// kitty state on every frame because any cell change can move
|
||||
// an image. If the virtual placements were removed, this will
|
||||
// be set to false on the next update.
|
||||
self.kitty_virtual
|
||||
}
|
||||
|
||||
/// Update the Kitty graphics state from the terminal's image storage.
|
||||
///
|
||||
/// This reads/writes state used by drawing. The caller is responsible
|
||||
/// for clearing `storage.dirty` afterwards (Zig: `defer storage.dirty =
|
||||
/// false`).
|
||||
///
|
||||
/// Rust deviation: Zig takes the whole `*Terminal`; the Rust kitty
|
||||
/// storage lives beside the page list it tracks pins in, so the pieces
|
||||
/// are passed explicitly (`info` = `TerminalInfo::from_terminal(t)`).
|
||||
pub fn kitty_update(
|
||||
&mut self,
|
||||
storage: &ImageStorage,
|
||||
pages: &PageList,
|
||||
info: &TerminalInfo,
|
||||
cell_size: CellSize,
|
||||
) {
|
||||
// We always clear our previous placements no matter what because
|
||||
// we rebuild them from scratch.
|
||||
self.kitty_placements.clear();
|
||||
self.kitty_virtual = false;
|
||||
self.force_kitty_update = false;
|
||||
|
||||
// Go through our known images and if there are any that are no longer
|
||||
// in use then mark them to be freed.
|
||||
//
|
||||
// This never conflicts with the below because a placement can't
|
||||
// reference an image that doesn't exist.
|
||||
for (id, entry) in &mut self.images {
|
||||
match id {
|
||||
// We're only looking at Kitty images
|
||||
Id::Kitty(id) => {
|
||||
if storage.image_by_id(*id).is_none() {
|
||||
entry.image.mark_for_unload();
|
||||
}
|
||||
}
|
||||
Id::Overlay => {}
|
||||
}
|
||||
}
|
||||
|
||||
// The top-left and bottom-right corners of our viewport in screen
|
||||
// points. This lets us determine offsets and containment of placements.
|
||||
let (Some(top), Some(bot)) = (
|
||||
pages.get_top_left(Tag::Viewport),
|
||||
pages.get_bottom_right(Tag::Viewport),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let (Some(top_pt), Some(bot_pt)) = (
|
||||
pages.point_from_pin(Tag::Screen, top),
|
||||
pages.point_from_pin(Tag::Screen, bot),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let top_y = top_pt.coord().y;
|
||||
let bot_y = bot_pt.coord().y;
|
||||
|
||||
// Go through the placements and ensure the image is
|
||||
// on the GPU or else is ready to be sent to the GPU.
|
||||
for (key, p) in &storage.placements {
|
||||
// Special logic based on location
|
||||
match p.location {
|
||||
ghostty_terminal::kitty::graphics_storage::Location::Pin(_) => {}
|
||||
ghostty_terminal::kitty::graphics_storage::Location::Virtual => {
|
||||
// We need to mark virtual placements on our renderer so that
|
||||
// we know to rebuild in more scenarios since cell changes can
|
||||
// now trigger placement changes.
|
||||
self.kitty_virtual = true;
|
||||
|
||||
// We also continue out because virtual placements are
|
||||
// only triggered by the unicode placeholder, not by the
|
||||
// placement itself.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the image for the placement
|
||||
let Some(image) = storage.image_by_id(key.image_id) else {
|
||||
log::warn!(
|
||||
"missing image for placement, ignoring image_id={}",
|
||||
key.image_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(err) = self.prep_kitty_placement(pages, info, top_y, bot_y, image, p) {
|
||||
// For errors we log and continue. We try to place
|
||||
// other placements even if one fails.
|
||||
log::warn!("error preparing kitty placement err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
// If we have virtual placements then we need to scan for placeholders.
|
||||
if self.kitty_virtual {
|
||||
for virtual_p in unicode::placement_iterator(pages, top, Some(bot)) {
|
||||
if let Err(err) =
|
||||
self.prep_kitty_virtual_placement(storage, pages, &virtual_p, cell_size)
|
||||
{
|
||||
// For errors we log and continue. We try to place
|
||||
// other placements even if one fails.
|
||||
log::warn!("error preparing kitty placement err={err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the placements by their Z value.
|
||||
self.kitty_placements.sort_unstable_by(|lhs, rhs| {
|
||||
lhs.z.cmp(&rhs.z).then_with(|| {
|
||||
if lhs.image_id.z_less_than(rhs.image_id) {
|
||||
std::cmp::Ordering::Less
|
||||
} else if rhs.image_id.z_less_than(lhs.image_id) {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Find our indices. The values are sorted by z so we can
|
||||
// find the first placement out of bounds to find the limits.
|
||||
let bg_limit = i32::MIN / 2;
|
||||
let mut bg_end: Option<u32> = None;
|
||||
let mut text_end: Option<u32> = None;
|
||||
for (i, p) in self.kitty_placements.iter().enumerate() {
|
||||
if bg_end.is_none() && p.z >= bg_limit {
|
||||
bg_end = Some(i as u32);
|
||||
}
|
||||
if text_end.is_none() && p.z >= 0 {
|
||||
text_end = Some(i as u32);
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't see any images with a z > the bg limit,
|
||||
// then our bg end is the end of our placement list.
|
||||
self.kitty_bg_end = bg_end.unwrap_or(self.kitty_placements.len() as u32);
|
||||
// Same idea for the image_text_end.
|
||||
self.kitty_text_end = text_end.unwrap_or(self.kitty_placements.len() as u32);
|
||||
}
|
||||
|
||||
/// Get the viewport-relative position for this
|
||||
/// placement and add it to the placements list.
|
||||
fn prep_kitty_placement(
|
||||
&mut self,
|
||||
pages: &PageList,
|
||||
info: &TerminalInfo,
|
||||
top_y: u32,
|
||||
bot_y: u32,
|
||||
image: &KittyImage,
|
||||
p: &ghostty_terminal::kitty::graphics_storage::Placement,
|
||||
) -> Result<(), PrepImageError> {
|
||||
// Get the rect for the placement. If this placement doesn't have
|
||||
// a rect then its virtual or something so skip it.
|
||||
let Some(rect) = p.rect(image, info, pages) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// This is expensive but necessary.
|
||||
let (Some(top_pt), Some(bot_pt)) = (
|
||||
pages.point_from_pin(Tag::Screen, rect.top_left),
|
||||
pages.point_from_pin(Tag::Screen, rect.bottom_right),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let img_top_y = top_pt.coord().y;
|
||||
let img_bot_y = bot_pt.coord().y;
|
||||
|
||||
// If the selection isn't within our viewport then skip it.
|
||||
if img_top_y > bot_y {
|
||||
return Ok(());
|
||||
}
|
||||
if img_bot_y < top_y {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// We need to prep this image for upload if it isn't in the
|
||||
// cache OR it is in the cache but the transmit time doesn't
|
||||
// match meaning this image is different.
|
||||
self.prep_kitty_image(image)?;
|
||||
|
||||
// Calculate the dimensions of our image, taking in to
|
||||
// account the rows / columns specified by the placement.
|
||||
let dest_size = p.calculated_size(image, info);
|
||||
|
||||
// Calculate the source rectangle
|
||||
let source_x = image.width.min(p.source_x);
|
||||
let source_y = image.height.min(p.source_y);
|
||||
let source_width = if p.source_width > 0 {
|
||||
(image.width - source_x).min(p.source_width)
|
||||
} else {
|
||||
image.width
|
||||
};
|
||||
let source_height = if p.source_height > 0 {
|
||||
(image.height - source_y).min(p.source_height)
|
||||
} else {
|
||||
image.height
|
||||
};
|
||||
|
||||
// Get the viewport-relative Y position of the placement.
|
||||
let y_pos = img_top_y as i32 - top_y as i32;
|
||||
|
||||
// Accumulate the placement
|
||||
if dest_size.width > 0 && dest_size.height > 0 {
|
||||
self.kitty_placements.push(Placement {
|
||||
image_id: Id::Kitty(image.id),
|
||||
x: i32::from(rect.top_left.x),
|
||||
y: y_pos,
|
||||
z: p.z,
|
||||
width: dest_size.width,
|
||||
height: dest_size.height,
|
||||
cell_offset_x: p.x_offset,
|
||||
cell_offset_y: p.y_offset,
|
||||
source_x,
|
||||
source_y,
|
||||
source_width,
|
||||
source_height,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prep_kitty_virtual_placement(
|
||||
&mut self,
|
||||
storage: &ImageStorage,
|
||||
pages: &PageList,
|
||||
p: &unicode::Placement,
|
||||
cell_size: CellSize,
|
||||
) -> Result<(), PrepImageError> {
|
||||
let Some(image) = storage.image_by_id(p.image_id) else {
|
||||
log::warn!(
|
||||
"missing image for virtual placement, ignoring image_id={}",
|
||||
p.image_id
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let rp = match p.render_placement(storage, image, cell_size.width, cell_size.height) {
|
||||
Ok(rp) => rp,
|
||||
Err(err) => {
|
||||
log::warn!("error rendering virtual placement err={err}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// If our placement is zero sized then we don't do anything.
|
||||
if rp.dest_width == 0 || rp.dest_height == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// This should be impossible with virtual placements because we should
|
||||
// only ever be looking at virtual placements that are in our
|
||||
// viewport in the renderer and virtual placements only ever take
|
||||
// up one row.
|
||||
let Some(viewport) = pages.point_from_pin(Tag::Viewport, rp.top_left) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Prepare the image for the GPU and store the placement.
|
||||
self.prep_kitty_image(image)?;
|
||||
self.kitty_placements.push(Placement {
|
||||
image_id: Id::Kitty(image.id),
|
||||
x: i32::from(rp.top_left.x),
|
||||
y: viewport.coord().y as i32,
|
||||
z: -1,
|
||||
width: rp.dest_width,
|
||||
height: rp.dest_height,
|
||||
cell_offset_x: rp.offset_x,
|
||||
cell_offset_y: rp.offset_y,
|
||||
source_x: rp.source_x,
|
||||
source_y: rp.source_y,
|
||||
source_width: rp.source_width,
|
||||
source_height: rp.source_height,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepare an image for upload to the GPU.
|
||||
pub fn prep_image(
|
||||
&mut self,
|
||||
id: Id,
|
||||
transmit_time: Instant,
|
||||
pending: Pending,
|
||||
) -> Result<(), PrepImageError> {
|
||||
// If this image exists and its transmit time is the same we assume
|
||||
// it is the identical image so we don't need to send it to the GPU.
|
||||
if let Some(existing) = self.images.get(&id)
|
||||
&& existing.transmit_time == transmit_time
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Store it in the map. We own the data (the caller handed us an
|
||||
// owned copy).
|
||||
match self.images.get_mut(&id) {
|
||||
None => {
|
||||
self.images.insert(
|
||||
id,
|
||||
Entry {
|
||||
image: Image::Pending(pending),
|
||||
transmit_time,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(entry) => entry.image.mark_for_replace(Image::Pending(pending)),
|
||||
}
|
||||
|
||||
let Some(entry) = self.images.get_mut(&id) else {
|
||||
return Ok(());
|
||||
};
|
||||
// If any error happens, we unload the image and it is invalid.
|
||||
if let Err(err) = entry.image.prep_for_upload() {
|
||||
log::warn!("error preparing image for upload err={err}");
|
||||
entry.image.mark_for_unload();
|
||||
return Err(PrepImageError::ImageConversionError);
|
||||
}
|
||||
entry.transmit_time = transmit_time;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepare the provided Kitty image for upload to the GPU by copying its
|
||||
/// data and setting it to the pending state.
|
||||
fn prep_kitty_image(&mut self, image: &KittyImage) -> Result<(), PrepImageError> {
|
||||
let pixel_format = match image.format {
|
||||
TransmissionFormat::Gray => PixelFormat::Gray,
|
||||
TransmissionFormat::GrayAlpha => PixelFormat::GrayAlpha,
|
||||
TransmissionFormat::Rgb => PixelFormat::Rgb,
|
||||
TransmissionFormat::Rgba => PixelFormat::Rgba,
|
||||
// should be decoded by now
|
||||
TransmissionFormat::Png => {
|
||||
log::warn!("png kitty image was not decoded id={}", image.id);
|
||||
return Err(PrepImageError::ImageConversionError);
|
||||
}
|
||||
};
|
||||
self.prep_image(
|
||||
Id::Kitty(image.id),
|
||||
image.transmit_time,
|
||||
Pending {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
pixel_format,
|
||||
data: image.data.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a single image placement on the grid.
|
||||
/// A placement is a request to render an instance of an image.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Placement {
|
||||
/// The image being rendered. This MUST be in the image map.
|
||||
pub image_id: Id,
|
||||
|
||||
/// The grid x/y where this placement is located.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub z: i32,
|
||||
|
||||
/// The width/height of the placed image.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
|
||||
/// The offset in pixels from the top left of the cell.
|
||||
/// This is clamped to the size of a cell.
|
||||
pub cell_offset_x: u32,
|
||||
pub cell_offset_y: u32,
|
||||
|
||||
/// The source rectangle of the placement.
|
||||
pub source_x: u32,
|
||||
pub source_y: u32,
|
||||
pub source_width: u32,
|
||||
pub source_height: u32,
|
||||
}
|
||||
|
||||
/// Image identifier used to store and lookup images.
|
||||
///
|
||||
/// This is tagged by different image types to make it easier to
|
||||
/// store different kinds of images in the same map without having
|
||||
/// to worry about ID collisions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Id {
|
||||
/// Image sent to the terminal state via the kitty graphics protocol.
|
||||
/// The value is the ID assigned by the terminal.
|
||||
Kitty(u32),
|
||||
|
||||
/// Debug overlay. This is always composited down to a single
|
||||
/// image for now. In the future we can support layers here if we want.
|
||||
Overlay,
|
||||
}
|
||||
|
||||
impl Id {
|
||||
/// Z-ordering tie-breaker for images with the same z value.
|
||||
pub fn z_less_than(self, rhs: Id) -> bool {
|
||||
match (self, rhs) {
|
||||
// Kitty images always sort before (lower z) non-kitty images.
|
||||
(Id::Kitty(_), Id::Overlay) => true,
|
||||
(Id::Overlay, Id::Kitty(_)) => false,
|
||||
(Id::Kitty(l), Id::Kitty(r)) => l < r,
|
||||
// No sensical ordering
|
||||
(Id::Overlay, Id::Overlay) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel formats of pending image data.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PixelFormat {
|
||||
/// 1 byte per pixel grayscale.
|
||||
Gray,
|
||||
/// 2 bytes per pixel grayscale + alpha.
|
||||
GrayAlpha,
|
||||
/// 3 bytes per pixel RGB.
|
||||
Rgb,
|
||||
/// 3 byte per pixel BGR.
|
||||
Bgr,
|
||||
/// 4 byte per pixel RGBA.
|
||||
Rgba,
|
||||
/// 4 byte per pixel BGRA.
|
||||
Bgra,
|
||||
}
|
||||
|
||||
impl PixelFormat {
|
||||
/// Get bytes per pixel for this format.
|
||||
#[inline]
|
||||
pub fn bpp(self) -> usize {
|
||||
match self {
|
||||
PixelFormat::Gray => 1,
|
||||
PixelFormat::GrayAlpha => 2,
|
||||
PixelFormat::Rgb | PixelFormat::Bgr => 3,
|
||||
PixelFormat::Rgba | PixelFormat::Bgra => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending image data that needs to be uploaded to the GPU.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Pending {
|
||||
pub height: u32,
|
||||
pub width: u32,
|
||||
pub pixel_format: PixelFormat,
|
||||
|
||||
/// Data is always expected to be (width * height * bpp).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// The expected number of bytes of image data.
|
||||
pub fn len(&self) -> usize {
|
||||
self.width as usize * self.height as usize * self.pixel_format.bpp()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
/// Convert to RGBA in place (the wuffs swizzle step). No-op for RGBA.
|
||||
#[allow(clippy::chunks_exact_to_as_chunks)]
|
||||
fn convert(&mut self) -> Result<(), ConvertError> {
|
||||
// As things stand, we currently convert all images to RGBA before
|
||||
// uploading to the GPU. This just makes things easier. In the future
|
||||
// we may want to support other formats.
|
||||
if self.pixel_format == PixelFormat::Rgba {
|
||||
return Ok(());
|
||||
}
|
||||
let n = self.width as usize * self.height as usize;
|
||||
let bpp = self.pixel_format.bpp();
|
||||
if self.data.len() < n * bpp {
|
||||
return Err(ConvertError::ShortData);
|
||||
}
|
||||
let src = &self.data[..n * bpp];
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
match self.pixel_format {
|
||||
PixelFormat::Gray => {
|
||||
for &g in src {
|
||||
out.extend_from_slice(&[g, g, g, 255]);
|
||||
}
|
||||
}
|
||||
PixelFormat::GrayAlpha => {
|
||||
for px in src.chunks_exact(2) {
|
||||
out.extend_from_slice(&[px[0], px[0], px[0], px[1]]);
|
||||
}
|
||||
}
|
||||
PixelFormat::Rgb => {
|
||||
for px in src.chunks_exact(3) {
|
||||
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
|
||||
}
|
||||
}
|
||||
PixelFormat::Bgr => {
|
||||
for px in src.chunks_exact(3) {
|
||||
out.extend_from_slice(&[px[2], px[1], px[0], 255]);
|
||||
}
|
||||
}
|
||||
PixelFormat::Bgra => {
|
||||
for px in src.chunks_exact(4) {
|
||||
out.extend_from_slice(&[px[2], px[1], px[0], px[3]]);
|
||||
}
|
||||
}
|
||||
PixelFormat::Rgba => unreachable!(),
|
||||
}
|
||||
self.data = out;
|
||||
self.pixel_format = PixelFormat::Rgba;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConvertError {
|
||||
#[error("image data is shorter than width * height * bpp")]
|
||||
ShortData,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UploadError {
|
||||
#[error(transparent)]
|
||||
Convert(#[from] ConvertError),
|
||||
/// Texture creation failed, usually a GPU memory issue.
|
||||
#[error("texture upload failed")]
|
||||
UploadFailed,
|
||||
}
|
||||
|
||||
/// The state for a single image that is to be rendered.
|
||||
pub enum Image {
|
||||
/// The image data is pending upload to the GPU.
|
||||
Pending(Pending),
|
||||
|
||||
/// This is the same as the pending states but there is
|
||||
/// a texture already allocated that we want to replace.
|
||||
Replace { texture: Texture, pending: Pending },
|
||||
|
||||
/// The image is uploaded and ready to be used.
|
||||
Ready(Texture),
|
||||
|
||||
/// The image isn't uploaded yet but is scheduled to be unloaded.
|
||||
UnloadPending(Pending),
|
||||
/// The image is uploaded and is scheduled to be unloaded.
|
||||
UnloadReady(Texture),
|
||||
/// The image is uploaded and scheduled to be replaced
|
||||
/// with new data, but it's also scheduled to be unloaded.
|
||||
UnloadReplace { texture: Texture, pending: Pending },
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Mark this image for unload whatever state it is in.
|
||||
pub fn mark_for_unload(&mut self) {
|
||||
let old = std::mem::replace(self, Image::Pending(Pending::empty()));
|
||||
*self = match old {
|
||||
v @ (Image::UnloadPending(_) | Image::UnloadReplace { .. } | Image::UnloadReady(_)) => {
|
||||
v
|
||||
}
|
||||
Image::Ready(t) => Image::UnloadReady(t),
|
||||
Image::Pending(p) => Image::UnloadPending(p),
|
||||
Image::Replace { texture, pending } => Image::UnloadReplace { texture, pending },
|
||||
};
|
||||
}
|
||||
|
||||
/// Mark the current image to be replaced with a pending one. This will
|
||||
/// attempt to update the existing texture if we have one, otherwise it
|
||||
/// will act like a new upload.
|
||||
pub fn mark_for_replace(&mut self, img: Image) {
|
||||
let Image::Pending(new_pending) = img else {
|
||||
debug_assert!(false, "mark_for_replace requires a pending image");
|
||||
return;
|
||||
};
|
||||
|
||||
// Take the current state apart; any pending data we have is dropped
|
||||
// (freed), and an existing texture is reused in the replace.
|
||||
let old = std::mem::replace(self, Image::Pending(Pending::empty()));
|
||||
*self = match old {
|
||||
Image::Ready(t) | Image::UnloadReady(t) => Image::Replace {
|
||||
texture: t,
|
||||
pending: new_pending,
|
||||
},
|
||||
Image::Replace { texture, .. } | Image::UnloadReplace { texture, .. } => {
|
||||
Image::Replace {
|
||||
texture,
|
||||
pending: new_pending,
|
||||
}
|
||||
}
|
||||
Image::Pending(_) | Image::UnloadPending(_) => Image::Pending(new_pending),
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns true if this image is pending upload.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
self.pending().is_some()
|
||||
}
|
||||
|
||||
/// Returns true if this image has an associated texture.
|
||||
pub fn has_texture(&self) -> bool {
|
||||
self.texture().is_some()
|
||||
}
|
||||
|
||||
/// Returns true if this image is marked for unload.
|
||||
pub fn is_unloading(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Image::UnloadPending(_) | Image::UnloadReplace { .. } | Image::UnloadReady(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// The texture if the image is ready or unload_ready (usable for drawing).
|
||||
pub fn ready_texture(&self) -> Option<&Texture> {
|
||||
match self {
|
||||
Image::Ready(t) | Image::UnloadReady(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pending(&self) -> Option<&Pending> {
|
||||
match self {
|
||||
Image::Pending(p) | Image::UnloadPending(p) => Some(p),
|
||||
Image::Replace { pending, .. } | Image::UnloadReplace { pending, .. } => Some(pending),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_mut(&mut self) -> Option<&mut Pending> {
|
||||
match self {
|
||||
Image::Pending(p) | Image::UnloadPending(p) => Some(p),
|
||||
Image::Replace { pending, .. } | Image::UnloadReplace { pending, .. } => Some(pending),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn texture(&self) -> Option<&Texture> {
|
||||
match self {
|
||||
Image::Ready(t) | Image::UnloadReady(t) => Some(t),
|
||||
Image::Replace { texture, .. } | Image::UnloadReplace { texture, .. } => Some(texture),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare the pending image data for upload to the GPU.
|
||||
/// This doesn't need GPU access so is safe to call any time.
|
||||
pub fn prep_for_upload(&mut self) -> Result<(), ConvertError> {
|
||||
if let Some(p) = self.pending_mut() {
|
||||
p.convert()
|
||||
} else {
|
||||
debug_assert!(false, "prep_for_upload requires pending data");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the pending image to the GPU and change the state of this
|
||||
/// image to ready.
|
||||
pub fn upload(&mut self, gpu: &Gpu<'_>) -> Result<(), UploadError> {
|
||||
// No error recovery is required after this call because it just
|
||||
// converts in place and is idempotent.
|
||||
self.prep_for_upload()?;
|
||||
|
||||
// Get our pending info
|
||||
let Some(p) = self.pending() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Create our texture
|
||||
let texture = Texture::init(
|
||||
gpu.gl,
|
||||
gpu.texture_options,
|
||||
p.width as usize,
|
||||
p.height as usize,
|
||||
Some(&p.data),
|
||||
)
|
||||
.map_err(|_| UploadError::UploadFailed)?;
|
||||
|
||||
// Uploaded. We can now clear our data and change our state.
|
||||
//
|
||||
// NOTE: For the `replace` state, this frees the old texture. We
|
||||
// don't currently actually replace the existing texture
|
||||
// in-place but that is an optimization we can do later.
|
||||
*self = Image::Ready(texture);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
fn empty() -> Pending {
|
||||
Pending {
|
||||
height: 0,
|
||||
width: 0,
|
||||
pixel_format: PixelFormat::Rgba,
|
||||
data: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pending(fmt: PixelFormat, data: Vec<u8>, w: u32, h: u32) -> Pending {
|
||||
Pending {
|
||||
width: w,
|
||||
height: h,
|
||||
pixel_format: fmt,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_format_bpp() {
|
||||
assert_eq!(PixelFormat::Gray.bpp(), 1);
|
||||
assert_eq!(PixelFormat::GrayAlpha.bpp(), 2);
|
||||
assert_eq!(PixelFormat::Rgb.bpp(), 3);
|
||||
assert_eq!(PixelFormat::Bgr.bpp(), 3);
|
||||
assert_eq!(PixelFormat::Rgba.bpp(), 4);
|
||||
assert_eq!(PixelFormat::Bgra.bpp(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_to_rgba() {
|
||||
let mut p = pending(PixelFormat::Gray, vec![7], 1, 1);
|
||||
p.convert().unwrap();
|
||||
assert_eq!(p.data, [7, 7, 7, 255]);
|
||||
assert_eq!(p.pixel_format, PixelFormat::Rgba);
|
||||
|
||||
let mut p = pending(PixelFormat::GrayAlpha, vec![7, 9], 1, 1);
|
||||
p.convert().unwrap();
|
||||
assert_eq!(p.data, [7, 7, 7, 9]);
|
||||
|
||||
let mut p = pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1);
|
||||
p.convert().unwrap();
|
||||
assert_eq!(p.data, [1, 2, 3, 255]);
|
||||
|
||||
let mut p = pending(PixelFormat::Bgr, vec![1, 2, 3], 1, 1);
|
||||
p.convert().unwrap();
|
||||
assert_eq!(p.data, [3, 2, 1, 255]);
|
||||
|
||||
let mut p = pending(PixelFormat::Bgra, vec![1, 2, 3, 4], 1, 1);
|
||||
p.convert().unwrap();
|
||||
assert_eq!(p.data, [3, 2, 1, 4]);
|
||||
|
||||
let mut p = pending(PixelFormat::Rgba, vec![1, 2, 3, 4], 1, 1);
|
||||
p.convert().unwrap();
|
||||
assert_eq!(p.data, [1, 2, 3, 4]);
|
||||
|
||||
// Short data is an error rather than a panic.
|
||||
let mut p = pending(PixelFormat::Rgb, vec![1, 2], 1, 1);
|
||||
assert!(p.convert().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_z_ordering() {
|
||||
assert!(Id::Kitty(5).z_less_than(Id::Overlay));
|
||||
assert!(!Id::Overlay.z_less_than(Id::Kitty(5)));
|
||||
assert!(Id::Kitty(1).z_less_than(Id::Kitty(2)));
|
||||
assert!(!Id::Kitty(2).z_less_than(Id::Kitty(1)));
|
||||
assert!(!Id::Overlay.z_less_than(Id::Overlay));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_transitions_without_gpu() {
|
||||
let mut img = Image::Pending(pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1));
|
||||
assert!(img.is_pending());
|
||||
assert!(!img.is_unloading());
|
||||
assert!(!img.has_texture());
|
||||
img.prep_for_upload().unwrap();
|
||||
assert_eq!(img.pending().unwrap().data, [1, 2, 3, 255]);
|
||||
|
||||
// Replace a pending image with another pending one.
|
||||
img.mark_for_replace(Image::Pending(pending(
|
||||
PixelFormat::Rgba,
|
||||
vec![9, 9, 9, 9],
|
||||
1,
|
||||
1,
|
||||
)));
|
||||
assert!(matches!(img, Image::Pending(_)));
|
||||
assert_eq!(img.pending().unwrap().data, [9, 9, 9, 9]);
|
||||
|
||||
img.mark_for_unload();
|
||||
assert!(img.is_unloading());
|
||||
assert!(matches!(img, Image::UnloadPending(_)));
|
||||
// Idempotent.
|
||||
img.mark_for_unload();
|
||||
assert!(matches!(img, Image::UnloadPending(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prep_image_same_transmit_time_is_noop() {
|
||||
let mut s = State::new();
|
||||
let t = Instant::now();
|
||||
s.prep_image(
|
||||
Id::Kitty(1),
|
||||
t,
|
||||
pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1),
|
||||
)
|
||||
.unwrap();
|
||||
// Same transmit time: data is not replaced.
|
||||
s.prep_image(
|
||||
Id::Kitty(1),
|
||||
t,
|
||||
pending(PixelFormat::Rgb, vec![9, 9, 9], 1, 1),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.images[&Id::Kitty(1)].image.pending().unwrap().data,
|
||||
[1, 2, 3, 255]
|
||||
);
|
||||
// A different transmit time replaces it.
|
||||
s.prep_image(
|
||||
Id::Kitty(1),
|
||||
Instant::now() + std::time::Duration::from_millis(1),
|
||||
pending(PixelFormat::Rgb, vec![9, 9, 9], 1, 1),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.images[&Id::Kitty(1)].image.pending().unwrap().data,
|
||||
[9, 9, 9, 255]
|
||||
);
|
||||
}
|
||||
|
||||
fn kitty_img(id: u32, w: u32, h: u32) -> KittyImage {
|
||||
KittyImage {
|
||||
id,
|
||||
width: w,
|
||||
height: h,
|
||||
format: TransmissionFormat::Rgba,
|
||||
data: vec![255; (w * h * 4) as usize],
|
||||
..KittyImage::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_update_builds_sorted_viewport_placements() {
|
||||
use ghostty_terminal::kitty::graphics_storage::{Location, Placement as KPlacement};
|
||||
use ghostty_terminal::point::{Coordinate, Point};
|
||||
use ghostty_terminal::terminal::Options;
|
||||
|
||||
let mut t = Terminal::new(Options::new(10, 10)).unwrap();
|
||||
t.width_px = 100;
|
||||
t.height_px = 200;
|
||||
|
||||
let mut storage = ImageStorage::default();
|
||||
storage
|
||||
.add_image(&mut t.screen_mut().pages, kitty_img(1, 20, 20))
|
||||
.unwrap();
|
||||
storage
|
||||
.add_image(&mut t.screen_mut().pages, kitty_img(2, 10, 10))
|
||||
.unwrap();
|
||||
storage
|
||||
.add_image(&mut t.screen_mut().pages, kitty_img(3, 10, 10))
|
||||
.unwrap();
|
||||
|
||||
// (image, z, x, y)
|
||||
for (id, z, x, y) in [
|
||||
(1u32, 5, 2u16, 3u32),
|
||||
(2, -1, 4, 1),
|
||||
(3, i32::MIN / 2 - 1, 0, 0),
|
||||
] {
|
||||
let pages = &mut t.screen_mut().pages;
|
||||
let pin = pages.pin(Point::Active(Coordinate { x, y })).unwrap();
|
||||
let loc = Location::Pin(pages.track_pin(pin).unwrap());
|
||||
let mut p = KPlacement::new(loc);
|
||||
p.z = z;
|
||||
storage.add_placement(pages, id, 0, p).unwrap();
|
||||
}
|
||||
// A virtual placement only flags that we need to rebuild every frame.
|
||||
storage
|
||||
.add_placement(
|
||||
&mut t.screen_mut().pages,
|
||||
1,
|
||||
7,
|
||||
KPlacement::new(Location::Virtual),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let info = TerminalInfo::from_terminal(&t);
|
||||
let mut s = State::new();
|
||||
s.kitty_update(
|
||||
&storage,
|
||||
&t.screen().pages,
|
||||
&info,
|
||||
CellSize {
|
||||
width: 10,
|
||||
height: 20,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(s.kitty_virtual);
|
||||
let order: Vec<_> = s
|
||||
.kitty_placements
|
||||
.iter()
|
||||
.map(|p| (p.image_id, p.z, p.x, p.y))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
[
|
||||
(Id::Kitty(3), i32::MIN / 2 - 1, 0, 0),
|
||||
(Id::Kitty(2), -1, 4, 1),
|
||||
(Id::Kitty(1), 5, 2, 3),
|
||||
]
|
||||
);
|
||||
// Below-bg is z < i32::MIN/2, below-text is z < 0, the rest is above.
|
||||
assert_eq!(s.kitty_bg_end, 1);
|
||||
assert_eq!(s.kitty_text_end, 2);
|
||||
// Native image size is used when the placement has no columns/rows.
|
||||
assert_eq!(
|
||||
(s.kitty_placements[2].width, s.kitty_placements[2].height),
|
||||
(20, 20)
|
||||
);
|
||||
// The images were converted and queued for upload.
|
||||
assert!(s.images[&Id::Kitty(1)].image.is_pending());
|
||||
|
||||
// Deleting an image from the storage marks it for unload next update.
|
||||
storage.images.remove(&2);
|
||||
s.kitty_update(
|
||||
&storage,
|
||||
&t.screen().pages,
|
||||
&info,
|
||||
CellSize {
|
||||
width: 10,
|
||||
height: 20,
|
||||
},
|
||||
);
|
||||
assert!(s.images[&Id::Kitty(2)].image.is_unloading());
|
||||
assert!(
|
||||
!s.kitty_placements
|
||||
.iter()
|
||||
.any(|p| p.image_id == Id::Kitty(2))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user