//! The generic renderer (`renderer/generic.zig`), instantiated for the //! OpenGL graphics API (`renderer.zig`: `pub const Renderer = GenericRenderer(OpenGL)`). //! //! The generic renderer builds the GPU cell buffers from a //! [`ghostty_terminal::render::RenderState`] and draws frames. //! //! # Threading model //! //! Like Ghostty on GTK (`must_draw_from_app_thread = true`), the work is split //! between two threads: //! //! * The **renderer thread** ([`crate::thread`]) owns the "update" half: //! [`Renderer::update_frame`] snapshots the terminal under the renderer //! mutex ([`crate::state::SharedState`]), shapes text, renders glyphs into the //! font atlases and rebuilds the CPU copies of the cell buffers. It never //! issues a GL call. //! * The **apprt main thread** (the one the `GtkGLArea` context is current on) //! owns the "draw" half: [`Renderer::draw_frame`] uploads buffers, atlases, //! images and the background image, then issues all GL calls. The renderer //! thread merely *requests* a draw through [`Host::redraw_surface`]; the //! apprt answers by calling `queue_render` on the GLArea whose `render` //! signal handler calls `draw_frame`. //! //! The renderer state is split to make that safe without `unsafe`: //! //! * `draw` ([`DrawState`], Zig's `draw_mutex`): everything `draw_frame` //! reads. Held by the renderer thread only for the short "publish" part of //! `update_frame` (`rebuild_cells`, uniforms) and by `set_*`/`change_config`. //! * `thread` ([`ThreadState`]): data only the renderer thread touches //! (`RenderState`, font shaper + cache, overlay, search highlights). It is a //! `Mutex` only so that `Renderer` is `Sync`; it is uncontended. //! //! Lock order is always: renderer mutex (`SharedState`) is *not* held while //! taking `draw`; `thread` before `draw` before the font grid lock. //! //! # GL context requirements //! //! Everything that creates, uses or destroys GL objects ([`Renderer::new`], //! [`Renderer::draw_frame`], [`Renderer::display_realized`], //! [`Renderer::display_unrealized`], dropping the `Renderer`) must run on the //! thread with the GL context current. The renderer thread never does. use std::{ collections::HashSet, sync::{ Arc, Condvar, Mutex, MutexGuard, PoisonError, atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}, }, time::Instant, }; use ghostty_config::{ config::Config, path::Path as ConfigPath, types_a::{BoldColor, FontShapingBreak, TerminalColor, WindowPaddingColor}, types_b::{ AlphaBlending, BackgroundBlur, BackgroundImageFit, BackgroundImagePosition, WindowColorspace, }, }; use ghostty_font::{ Atlas, Presentation, SPRITE_INDEX, SharedGrid, Sprite, Style as FontStyle, face::{Constraint, ConstraintSize, RenderOptions}, nerd_font_attributes::get_constraint, shape::{self, Cache as ShaperCache, RunIterator, RunOptions, TextRun}, shaper::run::RunCell, }; use ghostty_input::Mods; use ghostty_terminal::{ highlight::Flattened, page::{self, Cell as PageCell, Wide}, page_list::Scrollbar, point::{Coordinate, Tag}, render::{Cursor as RenderCursor, Dirty, RenderState, Row as RenderRow}, style::{self, Style}, terminal::ScrollViewport, }; use ghostty_vt::{color::Rgb, modes::Mode, sgr::Underline}; use crate::{ backend::Health, cell::{self, Contents, Key as CellKey}, cursor::{self, Style as CursorStyle}, display_link::DisplayLink, graphics_api::{ ApiError, Attachment, AttachmentTarget, Draw, FrameCompletion, GpuBuffer, GpuFrame, GpuRenderPass, GpuShaders, GpuTarget, GpuTexture, GraphicsApi, Primitive, Step, TextureKind, }, image::{self, Gpu}, link, math, opengl::OpenGL, options::Options, overlay::{self, Overlay}, row::never_extend_bg, shader_types::{ BgImage, BgImageFit, BgImageInfo, BgImagePosition, Bools, CellBg, CellText, CellTextAtlas, PaddingExtend, Uniforms, }, shadertoy::{self, PathItem}, size::{CellSize, GridSize, Padding, Size}, state::{Preedit, SharedState}, }; /// The graphics API used by the renderer on Linux. /// Errors surfaced by the generic renderer. #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] Api(#[from] ApiError), #[error(transparent)] Shaper(#[from] ghostty_font::shape::ShapeError), #[error(transparent)] ShapeRun(#[from] ghostty_font::shape::ShapeRunError), #[error(transparent)] Grid(#[from] ghostty_font::shared_grid::Error), #[error(transparent)] Link(#[from] link::Error), #[error("terminal state error: {0}")] Terminal(page::Error), #[error("the swap chain is defunct (display unrealized)")] Defunct, } /// What the renderer needs from the surface/apprt (Zig: `surface_mailbox` /// and the `redraw_surface` app message). Implementations must not block for /// long: `scrollbar` and `redraw_surface` are "instant" pushes in Zig. pub trait Host: Send + Sync { /// `surface_mailbox.push(.{ .scrollbar = .. }, .instant)`. Returns true if /// the message was accepted (otherwise the renderer retries next frame). fn scrollbar(&self, scrollbar: Scrollbar) -> bool; /// `surface_mailbox.push(.{ .renderer_health = .. }, .forever)`. fn renderer_health(&self, health: Health); /// Ask the apprt to call [`Renderer::draw_frame`] from its main thread /// (`app_mailbox.push(.{ .redraw_surface = surface }, .instant)`). Called /// from the renderer thread because GTK must draw on the main thread /// (`must_draw_from_app_thread = true`). fn redraw_surface(&self) -> bool; } /// A host that discards everything. Useful for tests and headless use. #[derive(Debug, Default, Clone, Copy)] pub struct NullHost; impl Host for NullHost { fn scrollbar(&self, _: Scrollbar) -> bool { true } fn renderer_health(&self, _: Health) {} fn redraw_surface(&self) -> bool { true } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] enum HighlightTag { SearchMatch = 0, SearchMatchSelected = 1, } /// The configuration for this renderer that is derived from the main /// configuration (`DerivedConfig`). Owned and cheap to send in messages. #[derive(Clone)] pub struct DerivedConfig { pub font_thicken: bool, pub font_thicken_strength: u8, pub font_features: Vec, pub font_styles: [bool; 4], pub font_shaping_break: FontShapingBreak, pub cursor_color: Option, pub cursor_opacity: f64, pub cursor_text: Option, pub background: Rgb, pub background_opacity: f64, pub background_opacity_cells: bool, pub foreground: Rgb, pub selection_background: Option, pub selection_foreground: Option, pub search_background: TerminalColor, pub search_foreground: TerminalColor, pub search_selected_background: TerminalColor, pub search_selected_foreground: TerminalColor, pub bold_color: Option, pub faint_opacity: u8, pub min_contrast: f32, pub padding_color: WindowPaddingColor, pub custom_shaders: Vec, pub bg_image: Option, pub bg_image_opacity: f32, pub bg_image_position: BackgroundImagePosition, pub bg_image_fit: BackgroundImageFit, pub bg_image_repeat: bool, pub links: Arc, pub vsync: bool, pub colorspace: WindowColorspace, pub blending: AlphaBlending, pub background_blur: BackgroundBlur, pub scroll_to_bottom_on_output: bool, } impl DerivedConfig { pub fn new(config: &Config) -> Result { use ghostty_config::types_a::FontStyle as ConfigFontStyle; // Copy our shaders let custom_shaders = config .custom_shader .value .iter() .filter_map(|p| { let path = match p.path().to_syscall_path() { Ok(path) => path, Err(error) => { log::warn!("invalid shader path: {error}"); return None; } }; Some(match p { ConfigPath::Optional(_) => PathItem::Optional(path), ConfigPath::Required(_) => PathItem::Required(path), }) }) .collect(); // Get our font styles (index order: regular, bold, italic, bold_italic) let font_styles = [ true, config.font_style_bold != ConfigFontStyle::False, config.font_style_italic != ConfigFontStyle::False, config.font_style_bold_italic != ConfigFontStyle::False, ]; // Our link configs let links = link::Set::from_config(&config.link.links)?; Ok(DerivedConfig { background_opacity: config.background_opacity.clamp(0.0, 1.0), background_opacity_cells: config.background_opacity_cells, font_thicken: config.font_thicken, font_thicken_strength: config.font_thicken_strength, font_features: config.font_feature.list.clone(), font_styles, font_shaping_break: config.font_shaping_break, cursor_color: config.cursor_color, cursor_text: config.cursor_text, cursor_opacity: config.cursor_opacity.clamp(0.0, 1.0), background: config.background.to_terminal_rgb(), foreground: config.foreground.to_terminal_rgb(), bold_color: config.bold_color, faint_opacity: (config.faint_opacity * 255.0).ceil().clamp(0.0, 255.0) as u8, min_contrast: config.minimum_contrast as f32, padding_color: config.window_padding_color, selection_background: config.selection_background, selection_foreground: config.selection_foreground, search_background: config.search_background, search_foreground: config.search_foreground, search_selected_background: config.search_selected_background, search_selected_foreground: config.search_selected_foreground, custom_shaders, bg_image: config.background_image.clone(), bg_image_opacity: config.background_image_opacity, bg_image_position: config.background_image_position, bg_image_fit: config.background_image_fit, bg_image_repeat: config.background_image_repeat, links: Arc::new(links), vsync: config.window_vsync, colorspace: config.window_colorspace, blending: config.alpha_blending, background_blur: config.background_blur, scroll_to_bottom_on_output: config.scroll_to_bottom.output, }) } } impl std::fmt::Debug for DerivedConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("DerivedConfig").finish_non_exhaustive() } } // --------------------------------------------------------------------------- // GPU frame state /// State we need duplicated for every frame. Any state that could be in a /// data race between the GPU and CPU while a frame is being drawn should be /// in this struct. /// /// With OpenGL `swap_chain_count == 1` and frames complete synchronously /// (`glFinish`), so the semaphore of the Zig swap chain is not needed: the /// draw mutex already serializes `draw_frame`. struct FrameState { uniforms: G::Buffer, cells: G::Buffer, cells_bg: G::Buffer, grayscale: G::Texture, grayscale_modified: usize, color: G::Texture, color_modified: usize, target: Arc, /// See property of same name on Renderer for explanation. target_config_modified: usize, /// Buffer with the vertex data for our background image. bg_image_buffer: G::Buffer, /// See property of same name on Renderer for explanation. bg_image_buffer_modified: usize, /// Custom shader state, this is None if we have no custom shaders. custom_shader_state: Option>, /// The swap chain's atlas epoch this frame state last synced with. atlas_epoch: usize, } impl FrameState { fn new(api: &G, custom_shaders: bool) -> Result, Error> { // Uniform buffer contains exactly 1 uniform struct. The // uniform data will be zeroed so this must be set before // a frame is drawn. let uniforms = api.init_buffer::(1)?; // Create GPU buffers for our cells. // // We start them off with a size of 1, which will of course be // too small, but they will be resized as needed. let cells = api.init_buffer::(1)?; let cells_bg = api.init_buffer::(1)?; // Create a GPU buffer for our background image info. let bg_image_buffer = api.init_buffer::(1)?; // Initialize our textures for our font atlas. // // As with the buffers above, we start these off as small // as possible since they'll inevitably be resized anyway. // // (2x2 rather than Zig's 1x1: `Atlas::new` keeps a 1px border so the // smallest valid atlas is 2px; the texture is replaced on first sync.) let grayscale = api.init_atlas_texture(&Atlas::new(2, ghostty_font::atlas::Format::Grayscale))?; let color = api.init_atlas_texture(&Atlas::new(2, ghostty_font::atlas::Format::Bgra))?; let custom_shader_state = if custom_shaders { Some(CustomShaderState::new(api)?) } else { None }; // Initialize the target. Just as with the other resources, // start it off as small as we can since it'll be resized. let target = Arc::new(api.init_target(1, 1)?); Ok(FrameState { uniforms, cells, cells_bg, grayscale, grayscale_modified: 0, color, color_modified: 0, target, target_config_modified: 0, bg_image_buffer, bg_image_buffer_modified: 0, custom_shader_state, atlas_epoch: 0, }) } fn resize(&mut self, api: &G, width: usize, height: usize) -> Result<(), Error> { if let Some(state) = &mut self.custom_shader_state { state.resize(api, width, height)?; } self.target = Arc::new(api.init_target(width, height)?); Ok(()) } } /// State relevant to our custom shaders if we have any. struct CustomShaderState { /// When we have a custom shader state, we maintain a front /// and back texture which we use as a swap chain to render /// between when multiple custom shaders are defined. front_texture: G::Texture, back_texture: G::Texture, /// Shadertoy uses a sampler for accessing the various channel textures. /// Samplers are immutable and describe sampling properties so we can /// share the sampler across front/back textures. sampler: G::Sampler, uniforms: G::Buffer, } impl CustomShaderState { fn new(api: &G) -> Result, Error> { // Create a GPU buffer to hold our uniforms. let uniforms = api.init_buffer::(1)?; // Initialize the front and back textures at 1x1 px, this // is slightly wasteful but it's only done once so whatever. let front_texture = api.init_texture(TextureKind::CustomShader, 1, 1, None)?; let back_texture = api.init_texture(TextureKind::CustomShader, 1, 1, None)?; let sampler = api.init_sampler()?; Ok(CustomShaderState { front_texture, back_texture, sampler, uniforms, }) } /// Swap the front and back textures. fn swap(&mut self) { std::mem::swap(&mut self.front_texture, &mut self.back_texture); } fn resize(&mut self, api: &G, width: usize, height: usize) -> Result<(), Error> { self.front_texture = api.init_texture(TextureKind::CustomShader, width, height, None)?; self.back_texture = api.init_texture(TextureKind::CustomShader, width, height, None)?; Ok(()) } } /// The frame slots of a [`SwapChain`], shared with the frame completion /// callbacks (which may run on another thread and hand the frame state back). struct SwapChainInner { /// `None` while the slot's frame is in flight (or being drawn). slots: Mutex>>>, /// Signalled when a frame is returned (Zig: `frame_sema.post`). returned: Condvar, /// Bumped to force every frame's atlas textures to re-sync, including /// the ones that are in flight right now (see `FrameState::atlas_epoch`). atlas_epoch: AtomicUsize, } /// Swap chain which maintains multiple copies of the state needed to render /// a frame, so that we can start building the next frame while the previous /// frame is still being processed on the GPU (always one for OpenGL, see /// [`FrameState`]; three for Metal). /// /// Zig guards the frames with a semaphore; here taking a frame waits (on a /// condition variable) until its slot has been handed back by the frame's /// completion callback. struct SwapChain { inner: Arc>, /// Index of the most recently used frame state struct. frame_index: usize, /// Set to true when deinited, so that a defunct swap chain is ignored /// instead of being used or freed twice (`displayUnrealized`). defunct: bool, } impl SwapChain { fn new(api: &G, custom_shaders: bool) -> Result, Error> { let mut frames = Vec::with_capacity(G::SWAP_CHAIN_COUNT); for _ in 0..G::SWAP_CHAIN_COUNT { frames.push(Some(FrameState::new(api, custom_shaders)?)); } Ok(SwapChain { inner: Arc::new(SwapChainInner { slots: Mutex::new(frames), returned: Condvar::new(), atlas_epoch: AtomicUsize::new(0), }), frame_index: 0, defunct: false, }) } fn deinit(&mut self) { if self.defunct { return; } self.defunct = true; // Wait for all of our inflight draws to complete // so that we can cleanly deinit our GPU state. let mut slots = lock(&self.inner.slots); while slots.iter().any(Option::is_none) { slots = self .inner .returned .wait(slots) .unwrap_or_else(PoisonError::into_inner); } slots.clear(); } /// Get the next frame state to draw to. This waits until the frame is /// available. It must always be paired with a [`SwapChain::release_frame`] /// (directly, or through the frame completion callback). fn next_frame(&mut self) -> Result<(usize, FrameState), Error> { if self.defunct { return Err(Error::Defunct); } let mut slots = lock(&self.inner.slots); if slots.is_empty() { return Err(Error::Defunct); } self.frame_index = (self.frame_index + 1) % slots.len(); let idx = self.frame_index; let mut frame = loop { if let Some(frame) = slots[idx].take() { break frame; } slots = self .inner .returned .wait(slots) .unwrap_or_else(PoisonError::into_inner); }; drop(slots); // Update all our textures so that they sync on the next frame, if // the font grid changed since this frame state was last used. let epoch = self.inner.atlas_epoch.load(Ordering::SeqCst); if frame.atlas_epoch != epoch { frame.atlas_epoch = epoch; frame.grayscale_modified = 0; frame.color_modified = 0; } Ok((idx, frame)) } /// A handle that hands a frame state back to its slot. fn releaser(&self) -> Arc> { Arc::clone(&self.inner) } fn release_frame(&self, idx: usize, frame: FrameState) { SwapChainInner::release(&self.inner, idx, frame); } /// Make every frame re-upload its atlas textures on its next use. fn invalidate_atlases(&self) { self.inner.atlas_epoch.fetch_add(1, Ordering::SeqCst); } } impl SwapChainInner { /// Hand a frame state back to its slot (Zig: `releaseFrame`). fn release(this: &Arc, idx: usize, frame: FrameState) { let mut slots = lock(&this.slots); if let Some(slot) = slots.get_mut(idx) { *slot = Some(frame); } drop(slots); this.returned.notify_all(); } } /// The background image state (`bg_image: ?imagepkg.Image`). type BgImageState = Option>; // --------------------------------------------------------------------------- // Renderer state /// State protected by the draw mutex: everything `draw_frame` reads. struct DrawState { /// The configuration we need derived from the main config. config: DerivedConfig, /// Current font metrics defining our grid. grid_metrics: ghostty_font::Metrics, /// The size of everything. size: Size, /// True if the window is focused focused: bool, /// Flag to indicate that our focus state changed for custom /// shaders to update their state. custom_shader_focused_changed: bool, /// The most recent scrollbar state. We use this as a cache to /// determine if we need to notify the apprt that there was a /// scrollbar change. scrollbar: Scrollbar, scrollbar_dirty: bool, /// The current set of cells to render. This is rebuilt on every frame /// but we keep this around so that we don't reallocate. Each set of /// cells goes into a separate shader. cells: Contents, /// Set to true after rebuild_cells is called. This can be used /// to determine if any possible changes have been made to the /// cells for the draw call. cells_rebuilt: bool, /// The current GPU uniform values. uniforms: Uniforms, /// Custom shader uniform values. custom_shader_uniforms: shadertoy::Uniforms, /// Timestamp we rendered out first frame. first_frame_time: Option, /// Timestamp when we rendered out more recent frame. last_frame_time: Option, /// The font structures. font_grid: Arc, /// The images that we may render. images: image::State, /// True when a debug overlay image is currently present (Zig: `overlay != null`). has_overlay: bool, /// Background image, if we have one. bg_image: BgImageState, /// Background image vertex buffer. bg_image_buffer: BgImage, /// This value is used to force-update the swap chain copy /// of the background image buffer whenever we change it. bg_image_buffer_modified: usize, /// Graphics API state. api: G, /// Our swap chain (multiple buffering) swap_chain: SwapChain, /// This value is used to force-update swap chain targets in the /// event of a config change that requires it (such as blending mode). target_config_modified: usize, /// If something happened that requires us to reinitialize our shaders, /// this is set to true so that we can do that whenever possible. reinitialize_shaders: bool, /// Whether or not we have custom shaders. has_custom_shaders: bool, /// Our shader pipelines. None while defunct (display unrealized). shaders: Option, } /// State only touched by the renderer thread. struct ThreadState { /// The thread's copy of the derived config (kept in sync with /// `DrawState::config`). config: DerivedConfig, /// The render state we update per loop. terminal_state: RenderState, /// The number of frames since the last terminal state reset. /// We reset the terminal state after ~100,000 frames (about 10 to /// 15 minutes at 120Hz) to prevent wasted memory buildup from /// a large screen. terminal_state_frame_count: usize, /// Tracks the last bottom-right pin of the screen to detect new output. /// Used for scroll-to-bottom on output feature. last_bottom: Option<(ghostty_terminal::page_list::NodeId, u16)>, /// The most recent viewport matches so that we can render search /// matches in the visible frame. This is provided asynchronously /// from the search thread so we have the dirty flag to also note /// if we need to rebuild our cells to include search highlights. /// /// Note that the selections MAY BE INVALID (point to PageList nodes /// that do not exist anymore). These are validated by the render state. search_matches: Option>, search_selected_match: Option, search_matches_dirty: bool, /// Our overlay state, if any. overlay: Option, font_shaper: shape::Shaper, font_shaper_cache: ShaperCache, /// Scratch buffers reused while rebuilding rows. run_cells: Vec, shaped_cells: Vec, } /// The generic renderer. See the module docs for the threading model. pub struct Renderer { draw: Mutex>, thread: Mutex, /// Health of the most recently completed frame. health: Arc, /// Mirrors `DrawState::has_custom_shaders` so the renderer thread can /// read it without the draw lock (`hasAnimations`). has_animations: AtomicBool, /// The CVDisplayLink used to drive the rendering loop in sync with the /// display. Always `None` on platforms that don't support a display link /// (everything but macOS). display_link: Mutex>, /// The surface/apprt callbacks. host: Arc, } fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(PoisonError::into_inner) } /// How far a [`PreeditRange`] reaches within a row. struct PreeditRange { y: u16, x: [u16; 2], cp_offset: usize, } /// Load the custom shaders (`initShaders`). Errors are logged and result in /// no custom shaders. fn load_custom_shaders(config: &DerivedConfig) -> Vec { match shadertoy::load_from_files(&config.custom_shaders, G::CUSTOM_SHADER_TARGET) { Ok(v) => v, Err(err) => { log::warn!("error loading custom shaders err={err}"); Vec::new() } } } impl DrawState { /// Rebuild the shader pipelines (`initShaders`). fn init_shaders(&mut self) -> Result<(), Error> { // Load our custom shaders let custom_shaders = load_custom_shaders::(&self.config); let has_custom_shaders = !custom_shaders.is_empty(); let shaders = self.api.init_shaders(&custom_shaders)?; self.shaders = Some(shaders); self.has_custom_shaders = has_custom_shaders; Ok(()) } /// Update uniforms that are based on the font grid. fn update_font_grid_uniforms(&mut self) { self.uniforms.cell_size = [ self.grid_metrics.cell_width as f32, self.grid_metrics.cell_height as f32, ]; } /// Update uniforms that are based on the screen size. fn update_screen_size_uniforms(&mut self) { let terminal_size = self.size.terminal(); // Blank space around the grid. let blank: Padding = self.size.screen.blank_padding( self.size.padding, GridSize { columns: self.cells.size.columns, rows: self.cells.size.rows, }, CellSize { width: self.grid_metrics.cell_width, height: self.grid_metrics.cell_height, }, ) + self.size.padding; // Setup our uniforms self.uniforms.projection_matrix = math::ortho2d( -(self.size.padding.left as f32), (terminal_size.width + self.size.padding.right) as f32, (terminal_size.height + self.size.padding.bottom) as f32, -(self.size.padding.top as f32), ); self.uniforms.grid_padding = [ blank.top as f32, blank.right as f32, blank.bottom as f32, blank.left as f32, ]; self.uniforms.screen_size = [ self.size.screen.width as f32, self.size.screen.height as f32, ]; } /// Update the background image vertex buffer (CPU-side). /// /// This should be called if and when configs change that /// could affect the background image. fn update_bg_image_buffer(&mut self) { self.bg_image_buffer = BgImage { opacity: self.config.bg_image_opacity, info: BgImageInfo { position: match self.config.bg_image_position { BackgroundImagePosition::TopLeft => BgImagePosition::Tl, BackgroundImagePosition::TopCenter => BgImagePosition::Tc, BackgroundImagePosition::TopRight => BgImagePosition::Tr, BackgroundImagePosition::CenterLeft => BgImagePosition::Ml, BackgroundImagePosition::CenterCenter | BackgroundImagePosition::Center => { BgImagePosition::Mc } BackgroundImagePosition::CenterRight => BgImagePosition::Mr, BackgroundImagePosition::BottomLeft => BgImagePosition::Bl, BackgroundImagePosition::BottomCenter => BgImagePosition::Bc, BackgroundImagePosition::BottomRight => BgImagePosition::Br, }, fit: match self.config.bg_image_fit { BackgroundImageFit::Contain => BgImageFit::Contain, BackgroundImageFit::Cover => BgImageFit::Cover, BackgroundImageFit::Stretch => BgImageFit::Stretch, BackgroundImageFit::None => BgImageFit::None, }, repeat: self.config.bg_image_repeat, } .to_bits(), pad: [0; 3], }; // Signal that the buffer was modified. self.bg_image_buffer_modified = self.bg_image_buffer_modified.wrapping_add(1); } /// Call this any time the background image path changes. fn prep_background_image(&mut self) { // Then we try to load the background image if we have a path. if let Some(p) = &self.config.bg_image && let Some(image) = p .path() .to_syscall_path() .map_err(|error| log::warn!("invalid background image path: {error}")) .ok() .and_then(|path| load_background_image(&path)) .map(image::Image::Pending) { // If we have an existing background image, replace it. // Otherwise, set this as our background image directly. match &mut self.bg_image { Some(img) => img.mark_for_replace(image), None => self.bg_image = Some(image), } } else if self.config.bg_image.is_none() && let Some(img) = &mut self.bg_image { // If we don't have a background image path, mark our // background image for unload if we currently have one. img.mark_for_unload(); } } /// Update custom shader uniforms that depend on terminal state. /// /// This should be called in `update_frame` when terminal state changes. fn update_custom_shader_uniforms_from_state( &mut self, state: &RenderState, cursor_text: Option, ) { fn f4(c: Rgb) -> [f32; 4] { [ f32::from(c.r) / 255.0, f32::from(c.g) / 255.0, f32::from(c.b) / 255.0, 1.0, ] } // We only need to do this if we have custom shaders. if !self.has_custom_shaders { return; } // Only update when terminal state is dirty. if state.dirty == Dirty::False { return; } let selection_background = self.config.selection_background; let selection_foreground = self.config.selection_foreground; let uniforms = &mut self.custom_shader_uniforms; let colors = &state.colors; // 256-color palette for (i, color) in colors.palette.iter().enumerate() { uniforms.palette[i] = f4(*color); } // Background color uniforms.background_color = f4(colors.background); // Foreground color uniforms.foreground_color = f4(colors.foreground); // Cursor color if let Some(cursor_color) = colors.cursor { uniforms.cursor_color = f4(cursor_color); } // NOTE: the following could be optimized to follow a change in // config for a slight optimization however this is only 12 bytes // each being updated and likely isn't a cause for concern // // Deviation: Zig reads `.color` unconditionally, which is invalid for // the `cell-foreground`/`cell-background` variants; those are skipped. // Cursor text color if let Some(TerminalColor::Color(c)) = cursor_text { uniforms.cursor_text = f4(c.to_terminal_rgb()); } // Selection background color if let Some(TerminalColor::Color(c)) = selection_background { uniforms.selection_background_color = f4(c.to_terminal_rgb()); } // Selection foreground color if let Some(TerminalColor::Color(c)) = selection_foreground { uniforms.selection_foreground_color = f4(c.to_terminal_rgb()); } // Cursor visibility uniforms.cursor_visible = i32::from(state.cursor.visible); // Cursor style let cursor_style = CursorStyle::from_terminal(state.cursor.visual_style); uniforms.previous_cursor_style = uniforms.current_cursor_style; uniforms.current_cursor_style = cursor_style as i32; } /// Update per-frame custom shader uniforms. /// /// This should be called exactly once per frame, inside `draw_frame`. #[allow(clippy::float_cmp)] fn update_custom_shader_uniforms_for_frame(&mut self) { // We only need to do this if we have custom shaders. if !self.has_custom_shaders { return; } let now = Instant::now(); let first_frame_time = *self.first_frame_time.get_or_insert(now); let last_frame_time = self.last_frame_time.unwrap_or(now); self.last_frame_time = Some(now); let screen = self.size.screen; let padding = self.size.padding; let cell = self.size.cell; let cursor_glyph = self.cells.get_cursor_glyph(); let focused = self.focused; let y_is_down = G::CUSTOM_SHADER_Y_IS_DOWN; let uniforms = &mut self.custom_shader_uniforms; let since_ns = now.duration_since(first_frame_time).as_nanos() as f32; uniforms.time = since_ns / 1_000_000_000.0; let delta_ns = now.duration_since(last_frame_time).as_nanos() as f32; uniforms.time_delta = delta_ns / 1_000_000_000.0; uniforms.frame += 1; uniforms.resolution = [screen.width as f32, screen.height as f32, 1.0]; uniforms.channel_resolution[0] = [screen.width as f32, screen.height as f32, 1.0, 0.0]; if let Some(cursor) = cursor_glyph { let cursor_width = cursor.glyph_size[0] as f32; let cursor_height = cursor.glyph_size[1] as f32; // Left edge of the cell the cursor is in. let mut pixel_x = (u32::from(cursor.grid_pos[0]) * cell.width + padding.left) as f32; // Top edge, relative to the top of the // screen, of the cell the cursor is in. let mut pixel_y = (u32::from(cursor.grid_pos[1]) * cell.height + padding.top) as f32; // If +Y is up in our shaders, we need to flip the coordinate // so that it's instead the top edge of the cell relative to // the *bottom* of the screen. if !y_is_down { pixel_y = screen.height as f32 - pixel_y; } // Add the X bearing to get the -X (left) edge of the cursor. pixel_x += f32::from(cursor.bearings[0]); // How we deal with the Y bearing depends on which direction // is "up", since we want our final `pixel_y` value to be the // +Y edge of the cursor. if y_is_down { // As a reminder, the Y bearing is the distance from the // bottom of the cell to the top of the glyph, so to get // the +Y edge we need to add the cell height, subtract // the Y bearing, and add the glyph height to get the +Y // (bottom) edge of the cursor. pixel_y += cell.height as f32; pixel_y -= f32::from(cursor.bearings[1]); pixel_y += cursor.glyph_size[1] as f32; } else { // If the Y direction is reversed though, we instead want // the *top* edge of the cursor, which means we just need // to subtract the cell height and add the Y bearing. pixel_y -= cell.height as f32; pixel_y += f32::from(cursor.bearings[1]); } let new_cursor = [pixel_x, pixel_y, cursor_width, cursor_height]; let cursor_color = [ f32::from(cursor.color[0]) / 255.0, f32::from(cursor.color[1]) / 255.0, f32::from(cursor.color[2]) / 255.0, f32::from(cursor.color[3]) / 255.0, ]; let cursor_changed = new_cursor != uniforms.current_cursor || cursor_color != uniforms.current_cursor_color; if cursor_changed { uniforms.previous_cursor = uniforms.current_cursor; uniforms.previous_cursor_color = uniforms.current_cursor_color; uniforms.current_cursor = new_cursor; uniforms.current_cursor_color = cursor_color; uniforms.cursor_change_time = uniforms.time; } } // Update focus uniforms uniforms.focus = i32::from(focused); // If we need to update the time our focus state changed // then update it to our current frame time. This may not be // exactly correct since it is frame time, not exact focus // time, but focus time on its own isn't exactly correct anyways // since it comes async from a message. if self.custom_shader_focused_changed && self.focused { self.custom_shader_uniforms.time_focus = self.custom_shader_uniforms.time; self.custom_shader_focused_changed = false; } } /// Sync the atlas data to the given texture. This copies the bytes /// associated with the atlas to the given texture. If the atlas no /// longer fits into the texture, the texture will be resized. fn sync_atlas_texture(api: &G, atlas: &Atlas, texture: &mut G::Texture) -> Result<(), Error> { if atlas.size as usize > texture.width() { // Reallocate (the old texture is dropped/deleted) *texture = api.init_atlas_texture(atlas)?; } texture.replace_region(0, 0, atlas.size as usize, atlas.size as usize, &atlas.data)?; Ok(()) } } /// Make sure our bg image is uploaded if it needs to be. fn upload_background_image(bg_image: &mut BgImageState, gpu: &Gpu<'_, G>) { if let Some(bg) = bg_image { if bg.is_unloading() { *bg_image = None; return; } if bg.is_pending() && let Err(err) = bg.upload(gpu) { log::warn!("error uploading background image err={err}"); } } } /// Decode a background image file (`prepBackgroundImage`). Failures are /// logged and result in no image, as in Zig. fn load_background_image(path: &std::path::Path) -> Option { let shown = path.display(); // Read it (Max size of 4 GiB, for now.) let contents = match std::fs::read(path) { Ok(c) if u32::try_from(c.len()).is_ok() => c, Ok(_) => { log::warn!("background image file \"{shown}\" is too large"); return None; } Err(err) => { log::warn!("error opening background image file \"{shown}\": {err}"); return None; } }; // Figure out what type it probably is. let file_type = match detect_file_type(&contents) { FileType::Unknown => guess_file_type_from_extension(path), t => t, }; // Decode it if we know how. let (width, height, data) = match file_type { FileType::Png => match decode_png(&contents) { Ok(v) => v, Err(err) => { log::warn!("error decoding png background image \"{shown}\": {err}"); return None; } }, FileType::Jpeg => match decode_jpeg(&contents) { Ok(v) => v, Err(err) => { log::warn!("error decoding jpeg background image \"{shown}\": {err}"); return None; } }, FileType::Unknown => { log::warn!("Cannot determine file type for background image file \"{shown}\"!"); return None; } f => { log::warn!("Unsupported file type {f:?} for background image file \"{shown}\"!"); return None; } }; Some(image::Pending { width, height, pixel_format: image::PixelFormat::Rgba, data, }) } /// `file_type.zig` (the subset needed here). #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FileType { Jpeg, Png, Gif, Bmp, Qoi, Webp, Unknown, } fn detect_file_type(contents: &[u8]) -> FileType { const PNG: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; if contents.starts_with(PNG) { FileType::Png } else if contents.starts_with(&[0xFF, 0xD8, 0xFF]) { FileType::Jpeg } else if contents.starts_with(b"GIF87a") || contents.starts_with(b"GIF89a") { FileType::Gif } else if contents.starts_with(b"BM") { FileType::Bmp } else if contents.starts_with(b"qoif") { FileType::Qoi } else if contents.len() >= 12 && &contents[..4] == b"RIFF" && &contents[8..12] == b"WEBP" { FileType::Webp } else { FileType::Unknown } } fn guess_file_type_from_extension(path: &std::path::Path) -> FileType { let ext = path .extension() .and_then(|e| e.to_str()) .map(str::to_ascii_lowercase); match ext.as_deref() { Some("jpg" | "jpeg" | "jfif") => FileType::Jpeg, Some("png") => FileType::Png, Some("gif") => FileType::Gif, Some("bmp") => FileType::Bmp, Some("qoi") => FileType::Qoi, Some("webp") => FileType::Webp, _ => FileType::Unknown, } } /// Decode a PNG to straight (non-premultiplied) RGBA8. #[allow(clippy::chunks_exact_to_as_chunks)] fn decode_png(contents: &[u8]) -> Result<(u32, u32, Vec), String> { let mut decoder = png::Decoder::new(std::io::Cursor::new(contents)); decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); let mut reader = decoder.read_info().map_err(|e| e.to_string())?; let mut buf = vec![0; reader.output_buffer_size().ok_or("png too large")?]; let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?; buf.truncate(info.buffer_size()); let n = info.width as usize * info.height as usize; let rgba = match info.color_type { png::ColorType::Rgba => buf, png::ColorType::Rgb => buf .chunks_exact(3) .flat_map(|p| [p[0], p[1], p[2], 255]) .collect(), png::ColorType::GrayscaleAlpha => buf .chunks_exact(2) .flat_map(|p| [p[0], p[0], p[0], p[1]]) .collect(), png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(), png::ColorType::Indexed => return Err("unexpanded indexed png".into()), }; if rgba.len() != n * 4 { return Err("unexpected png buffer size".into()); } Ok((info.width, info.height, rgba)) } /// Decode a JPEG to RGBA8. #[allow(clippy::chunks_exact_to_as_chunks)] fn decode_jpeg(contents: &[u8]) -> Result<(u32, u32, Vec), String> { let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(contents)); let pixels = decoder.decode().map_err(|e| e.to_string())?; let info = decoder.info().ok_or("missing jpeg info")?; let rgba: Vec = match info.pixel_format { jpeg_decoder::PixelFormat::RGB24 => pixels .chunks_exact(3) .flat_map(|p| [p[0], p[1], p[2], 255]) .collect(), jpeg_decoder::PixelFormat::L8 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(), jpeg_decoder::PixelFormat::L16 => pixels .chunks_exact(2) .flat_map(|p| [p[0], p[0], p[0], 255]) .collect(), jpeg_decoder::PixelFormat::CMYK32 => pixels .chunks_exact(4) .flat_map(|p| [p[0], p[1], p[2], 255]) .collect(), }; Ok((u32::from(info.width), u32::from(info.height), rgba)) } /// Convert the config bold color to the terminal style's bold policy. fn bold_policy(c: Option) -> Option { c.map(|c| match c { BoldColor::Color(c) => style::BoldColor::Color(c.to_terminal_rgb()), BoldColor::Bright => style::BoldColor::Bright, }) } /// Resolve a configured [`TerminalColor`] against the cell colors /// (the repeated `.color / .cell-foreground / .cell-background` switch). /// `cell-foreground` is the foreground unless the cell is inverse, in which /// case it is the background (and vice versa for `cell-background`). fn resolve_terminal_color( c: TerminalColor, inverse: bool, fg_style: Option, bg_style: Option, ) -> Option { match c { TerminalColor::Color(color) => Some(color.to_terminal_rgb()), TerminalColor::CellForeground => { if inverse { bg_style } else { fg_style } } TerminalColor::CellBackground => { if inverse { fg_style } else { bg_style } } } } /// Mutable shaping context for a row rebuild. struct ShapeCtx<'a> { shaper: &'a mut shape::Shaper, cache: &'a mut ShaperCache, run_cells: &'a mut Vec, shaped: &'a mut Vec, } /// Fill `shaped` with the shaped cells for `run`, from the cache if possible /// (a copy is made because the shaper's buffer is only valid until the next /// shaping call). fn shaped_cells_for_run( shaper: &mut shape::Shaper, cache: &mut ShaperCache, shaped: &mut Vec, grid: &SharedGrid, run: &TextRun, ) -> Result<(), Error> { shaped.clear(); // Try to read the cells from the shaping cache if we can. if let Some(cells) = cache.get(run) { shaped.extend_from_slice(cells); return Ok(()); } // Otherwise we have to shape them. let new_cells = shaper.shape_run(grid, run)?; // Cache them: this is just a performance optimization. cache.put(run, new_cells); shaped.extend_from_slice(new_cells); Ok(()) } impl Renderer { /// Create the renderer (`Renderer.init`). /// /// `api` must already be prepared (OpenGL: [`OpenGL::prepare_context`], /// i.e. Zig `Renderer.surfaceInit`, with the context current on the /// calling thread; Metal: attached to its view): swap chain buffers, /// textures and shaders are created here. pub fn new(opts: Options, api: G) -> Result, Error> { let config = opts.config; let has_custom_shaders = !config.custom_shaders.is_empty(); // Prepare our swap chain let swap_chain = SwapChain::new(&api, has_custom_shaders)?; // Create the font shaper. let font_shaper = shape::Shaper::new(&shape::Options { features: config.font_features.clone(), })?; // Initialize all the data that requires a critical font section. let grid_metrics = opts.font_grid.metrics(); let mut draw = DrawState { config: config.clone(), grid_metrics, size: opts.size, focused: true, custom_shader_focused_changed: false, scrollbar: Scrollbar::default(), scrollbar_dirty: false, // Render state cells: Contents::new(), cells_rebuilt: false, uniforms: Uniforms { min_contrast: config.min_contrast, cursor_pos: [u16::MAX, u16::MAX], bg_color: [ config.background.r, config.background.g, config.background.b, // Note that if we're on macOS with glass effects // we'll disable background opacity but we handle // that in update_frame. (config.background_opacity * 255.0).round() as u8, ], bools: Bools { cursor_wide: false, use_display_p3: config.colorspace == WindowColorspace::DisplayP3, use_linear_blending: config.blending.is_linear(), use_linear_correction: config.blending == AlphaBlending::LinearCorrected, } .to_bits(), ..Uniforms::default() }, custom_shader_uniforms: shadertoy::Uniforms { resolution: [0.0, 0.0, 1.0], frame_rate: 60.0, // not currently updated focus: 1, // assume focused initially ..shadertoy::Uniforms::default() }, first_frame_time: None, last_frame_time: None, font_grid: opts.font_grid, images: image::State::new(), has_overlay: false, bg_image: None, bg_image_buffer: BgImage::default(), bg_image_buffer_modified: 0, api, swap_chain, target_config_modified: 0, reinitialize_shaders: false, has_custom_shaders: false, shaders: None, }; draw.init_shaders()?; // Ensure our default values above are correctly initialized. draw.update_font_grid_uniforms(); draw.update_screen_size_uniforms(); draw.update_bg_image_buffer(); draw.prep_background_image(); let thread = ThreadState { config, terminal_state: RenderState::new(), terminal_state_frame_count: 0, last_bottom: None, search_matches: None, search_selected_match: None, search_matches_dirty: false, overlay: None, font_shaper, font_shaper_cache: ShaperCache::new(), run_cells: Vec::new(), shaped_cells: Vec::new(), }; let has_animations = AtomicBool::new(draw.has_custom_shaders); // The display link is created here but only started in `loop_enter`. let display_link = if cfg!(target_os = "macos") && draw.config.vsync { DisplayLink::create_with_active_cg_displays() } else { None }; Ok(Renderer { display_link: Mutex::new(display_link), draw: Mutex::new(draw), thread: Mutex::new(thread), health: Arc::new(AtomicU8::new(Health::Healthy as u8)), has_animations, host: opts.host, }) } /// This is called by the apprt after the surface is reinitialized /// due to any of the events mentioned in the doc comment of /// [`Renderer::display_unrealized`] (Zig `displayRealized`). `realize` is /// the API's own hook ("if our API has to do things on realize, let it": /// OpenGL reloads its context). The GPU context must be current. pub fn display_realized_with(&self, realize: impl FnOnce(&mut G)) -> Result<(), Error> { // Lock the draw mutex so that we can // safely reinitialize our GPU resources. let mut d = lock(&self.draw); // If our API has to do things on realize, let it. realize(&mut d.api); // We assume that the swap chain was deinited in // `display_unrealized`, in which case it should be // marked defunct. If not, we have a problem. debug_assert!(d.swap_chain.defunct); // We reinitialize our shaders and our swap chain. d.init_shaders()?; self.has_animations .store(d.has_custom_shaders, Ordering::Relaxed); d.swap_chain = SwapChain::new(&d.api, d.has_custom_shaders)?; d.reinitialize_shaders = false; d.target_config_modified = 1; d.api.clear_last_target(); // The new atlas textures and buffers need to be refilled. d.update_bg_image_buffer(); Ok(()) } /// This is called by the GTK apprt when the surface is being destroyed. /// This can happen because the surface is being closed but also when /// moving the window between displays or splitting. The GL context /// must be current. pub fn display_unrealized(&self) { // Lock the draw mutex so that we can // safely deinitialize our GPU resources. (The thread state is locked // first, per the lock order.) let mut t = lock(&self.thread); let mut d = lock(&self.draw); // We deinit our swap chain and shaders. // // This will mark them as defunct so that they // can't be double-freed or used in draw calls. d.api.display_unrealized(); d.swap_chain.deinit(); d.shaders = None; d.api.clear_last_target(); // GPU textures of images and the background image die with the // context; drop them so they are re-uploaded after realize. d.images = image::State::new(); d.images.force_kitty_update = true; d.bg_image = None; d.prep_background_image(); t.terminal_state.dirty = Dirty::Full; } /// Mark the full screen as dirty so that we redraw everything. pub fn mark_dirty(&self) { lock(&self.thread).terminal_state.dirty = Dirty::Full; } /// True if our renderer has animations so that a higher frequency /// timer is used. pub fn has_animations(&self) -> bool { self.has_animations.load(Ordering::Relaxed) } /// True if our renderer is using vsync. If true, the renderer or apprt /// is responsible for triggering draw_now calls to the render thread. /// That is the only way to trigger a `draw_frame`. (Only true on macOS /// with a running display link.) pub fn has_vsync(&self) -> bool { lock(&self.display_link) .as_ref() .is_some_and(DisplayLink::is_running) } /// The font grid currently used for rendering (for apprts that need /// to read the primary face, e.g. macOS quicklook). pub fn font_grid(&self) -> Arc { Arc::clone(&lock(&self.draw).font_grid) } /// Called by the renderer thread when it begins (`threadEnter`). pub fn thread_enter(&self) { lock(&self.draw).api.thread_enter(); } /// Called by the renderer thread when it exits (`threadExit`). pub fn thread_exit(&self) { lock(&self.draw).api.thread_exit(); } /// Called by the renderer thread when it starts the main loop /// (`loopEnter`). `draw_now` forces an immediate draw on the renderer /// thread; the display link calls it on every vsync. pub fn loop_enter(self: &Arc, draw_now: Arc) { // If our API has to do things on loop enter, let it. (Metal draws // synchronously from the layer's display callback.) let weak = Arc::downgrade(self); lock(&self.draw).api.loop_enter(Arc::new(move || { if let Some(renderer) = weak.upgrade() && let Err(err) = renderer.draw_frame(true) { log::warn!("Error drawing frame in display callback, err={err}"); } })); // If we don't support a display link we have no work to do. let guard = lock(&self.display_link); let Some(link) = guard.as_ref() else { return; }; // To setup the display link we set our callback and we can start it // immediately. if let Err(err) = link.set_output_callback(draw_now) { log::warn!("error setting display link callback err={err}"); return; } let _ = link.start(); } /// Called by the renderer thread when it exits the main loop /// (`loopExit`). pub fn loop_exit(&self) { // If our API has to do things on loop exit, let it. lock(&self.draw).api.loop_exit(); // Stop our display link. If this fails its okay it just means // that we either never started it or the view its attached to // is gone which is fine. if let Some(link) = lock(&self.display_link).as_ref() { let _ = link.stop(); } } /// Called when we get an updated display ID for our display link /// (`setMacOSDisplayID`). pub fn set_macos_display_id(&self, id: u32) { let guard = lock(&self.display_link); let Some(link) = guard.as_ref() else { return; }; log::info!("updating display link display id={id}"); if let Err(err) = link.set_current_cg_display(id) { log::warn!("error setting display link display id err={err}"); } } /// The health of the most recently completed frame. pub fn health(&self) -> Health { if self.health.load(Ordering::SeqCst) == Health::Healthy as u8 { Health::Healthy } else { Health::Unhealthy } } /// The current size of everything as the renderer knows it. pub fn size(&self) -> Size { lock(&self.draw).size } /// Callback when the focus changes for the terminal this is rendering. /// /// Must be called on the render thread. pub fn set_focus(&self, focus: bool) { let mut d = lock(&self.draw); debug_assert_ne!(d.focused, focus); d.focused = focus; // Flag that we need to update our custom shaders d.custom_shader_focused_changed = true; // If we're not focused, then we want to stop the display link // because it is a waste of resources and we can move to pure // change-driven updates. if let Some(link) = lock(&self.display_link).as_ref() { let _ = if focus { link.start() } else { link.stop() }; } } /// Callback when the window is visible or occluded. /// /// Must be called on the render thread. pub fn set_visible(&self, visible: bool) { // If we're not visible, then we want to stop the display link // because it is a waste of resources and we can move to pure // change-driven updates. let focused = lock(&self.draw).focused; if let Some(link) = lock(&self.display_link).as_ref() { let _ = if visible && focused { link.start() } else { link.stop() }; } } /// Set the new font grid. /// /// Must be called on the render thread. pub fn set_font_grid(&self, grid: Arc) { let mut t = lock(&self.thread); let mut d = lock(&self.draw); // Get our metrics from the grid. This doesn't require a lock because // the metrics are never recalculated. let metrics = grid.metrics(); // Update our grid d.font_grid = grid; // Update all our textures so that they sync on the next frame. d.swap_chain.invalidate_atlases(); d.grid_metrics = metrics; // Reset our shaper cache. If our font changed (not just the size) then // the data in the shaper cache may be invalid and cannot be used, so we // always clear the cache just in case. t.font_shaper_cache = ShaperCache::new(); // Update cell size. d.size.cell = CellSize { width: metrics.cell_width, height: metrics.cell_height, }; // Update relevant uniforms d.update_font_grid_uniforms(); // Force a full rebuild, because cached rows may still reference // an outdated atlas from the old grid and this can cause garbage // to be rendered. t.terminal_state.dirty = Dirty::Full; } /// Update the configuration. #[allow(clippy::float_cmp)] pub fn change_config(&self, config: DerivedConfig) -> Result<(), Error> { let mut t = lock(&self.thread); let mut d = lock(&self.draw); // We always redo the font shaper in case font features changed. We // could check to see if there was an actual config change but this is // easier and rare enough to not cause performance issues. t.font_shaper = shape::Shaper::new(&shape::Options { features: config.font_features.clone(), })?; // We also need to reset the shaper cache so shaper info // from the previous font isn't reused for the new font. t.font_shaper_cache = ShaperCache::new(); // Set our new minimum contrast d.uniforms.min_contrast = config.min_contrast; // Set our new color space and blending let mut bools = Bools::from_bits(d.uniforms.bools); bools.use_display_p3 = config.colorspace == WindowColorspace::DisplayP3; bools.use_linear_blending = config.blending.is_linear(); bools.use_linear_correction = config.blending == AlphaBlending::LinearCorrected; d.uniforms.bools = bools.to_bits(); let bg_image_config_changed = d.config.bg_image_fit != config.bg_image_fit || d.config.bg_image_position != config.bg_image_position || d.config.bg_image_repeat != config.bg_image_repeat || d.config.bg_image_opacity != config.bg_image_opacity; let bg_image_changed = d.config.bg_image != config.bg_image; let old_blending = d.config.blending; let custom_shaders_changed = d.config.custom_shaders != config.custom_shaders; d.config = config.clone(); t.config = config; // If our background image path changed, prepare the new bg image. if bg_image_changed { d.prep_background_image(); } // If our background image config changed, update the vertex buffer. if bg_image_config_changed { d.update_bg_image_buffer(); } // Reset our viewport to force a rebuild, in case of a font change. t.terminal_state.dirty = Dirty::Full; let blending_changed = old_blending != d.config.blending; if blending_changed { // We update our API's blending mode. let blending = d.config.blending; d.api.set_blending(blending); // And indicate that we need to reinitialize our shaders. d.reinitialize_shaders = true; // And indicate that our swap chain targets need to // be re-created to account for the new blending mode. d.target_config_modified = d.target_config_modified.wrapping_add(1); } if custom_shaders_changed { d.reinitialize_shaders = true; } Ok(()) } /// Resize the screen. pub fn set_screen_size(&self, size: Size) { let mut d = lock(&self.draw); // We only actually need the padding from this, // everything else is derived elsewhere. d.size.padding = size.padding; d.update_screen_size_uniforms(); log::debug!("screen size size={size:?}"); } /// Set the search highlights (from the renderer thread mailbox). pub fn set_search_viewport_matches(&self, matches: Vec) { let mut t = lock(&self.thread); t.search_matches = Some(matches); t.search_matches_dirty = true; } /// Set the selected search match (from the renderer thread mailbox). pub fn set_search_selected_match(&self, m: Option) { let mut t = lock(&self.thread); t.search_selected_match = m; t.search_matches_dirty = true; } /// The callback the graphics API calls when a frame is completed /// (`renderer.frameCompleted`). Metal calls it from the command buffer's /// completion handler, so it owns handles instead of borrowing `self`. fn frame_completion(&self) -> FrameCompletion { let health = Arc::clone(&self.health); let host = Arc::clone(&self.host); Arc::new(move |h| frame_completed(&health, host.as_ref(), h)) } } /// Record the health of a completed frame and notify the surface on change. fn frame_completed(current: &AtomicU8, host: &dyn Host, health: Health) { // If our health value hasn't changed, then we do nothing. We don't // do a cmpxchg here because strict atomicity isn't important. if current.load(Ordering::SeqCst) != health as u8 { current.store(health as u8, Ordering::SeqCst); // Our health value changed, so we notify the surface so that it // can do something about it. host.renderer_health(health); } } impl Renderer { /// This is called by the GTK apprt after the surface is reinitialized /// due to any of the events mentioned in the doc comment of /// [`Renderer::display_unrealized`]. The GL context must be current. pub fn display_realized(&self, get_proc_address: F) -> Result<(), Error> where F: FnMut(&str) -> *const std::ffi::c_void, { self.display_realized_with(|api| api.display_realized(get_proc_address)) } /// Read back the pixels (RGBA8, bottom-up like `glReadPixels`) of the /// most recently presented frame. Intended for tests and screenshots; /// requires the GL context to be current. pub fn last_frame_pixels(&self) -> Option<(usize, usize, Vec)> { let d = lock(&self.draw); let target = d.api.last_target()?; let gl = d.api.gl()?.clone(); let bind = gl.bind_framebuffer( target.framebuffer(), crate::opengl::gl::FramebufferTarget::Read, ); let px = gl.read_pixels_rgba8(0, 0, target.width as i32, target.height as i32); gl.unbind_framebuffer(bind); Some((target.width, target.height, px)) } } // --------------------------------------------------------------------------- // update_frame (renderer thread) /// Data extracted from the terminal inside the critical section. struct Critical { links: HashSet, mouse_point: Option, mouse_mods: Mods, preedit: Option, scrollbar: Scrollbar, overlay_features: Vec, } /// The max number of frames before we throw away the `RenderState` /// (~12 minutes at 120Hz). const MAX_TERMINAL_STATE_FRAME_COUNT: usize = 100_000; impl Renderer { /// Update the frame data. /// /// This is the renderer thread half of the renderer: it locks /// `state` (the renderer mutex) briefly to snapshot the terminal and then /// rebuilds the CPU copy of the GPU cell buffers. It performs no GL calls. pub fn update_frame( &self, state: &SharedState, cursor_blink_visible: bool, ) -> Result<(), Error> { let mut guard = lock(&self.thread); let t = &mut *guard; // We fully deinit and reset the terminal state every so often // so that a particularly large terminal state doesn't cause // the renderer to hold on to retained memory. if t.terminal_state_frame_count >= MAX_TERMINAL_STATE_FRAME_COUNT { t.terminal_state = RenderState::new(); } t.terminal_state_frame_count += 1; // Update all our data as tightly as possible within the mutex. let mut critical = { let mut st = lock(state); let st = &mut *st; // If we're in a synchronized output state, we pause all rendering. if st.terminal.modes.get(Mode::SynchronizedOutput) { log::debug!("synchronized output started, skipping render"); return Ok(()); } // If scroll-to-bottom on output is enabled, check if the final line // changed by comparing the bottom-right pin. If the node or // y offset changed, new content was added to the screen. // Update this BEFORE we update our render state so we can // draw the new scrolled data immediately. if t.config.scroll_to_bottom_on_output && let Some(br) = st.terminal.screen().pages.get_bottom_right(Tag::Screen) && t.last_bottom != Some((br.node, br.y)) { // Update tracked pin state for next frame t.last_bottom = Some((br.node, br.y)); // Scroll st.terminal.scroll_viewport(ScrollViewport::Bottom); } // Update our terminal state t.terminal_state .update(&mut st.terminal) .map_err(Error::Terminal)?; // If our terminal state is dirty at all we need to redo // the viewport search. if t.terminal_state.dirty != Dirty::False { st.terminal.flags.search_viewport_dirty = true; } // Get our scrollbar out of the terminal. We synchronize // the scrollbar read with frame data updates because this // naturally limits the number of calls to this method (it // can be expensive) and also makes it so we don't need another // cross-thread mailbox message within the IO path. let scrollbar = st.terminal.screen().pages.scrollbar(); // Get our preedit state let preedit = st.preedit.clone(); // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // We only do this if the Kitty image state is dirty meaning only if // it changes. // // If we have any virtual references, we must also rebuild our // kitty state on every frame because any cell change can move // an image. { let mut d = lock(&self.draw); if d.images.kitty_requires_update(&st.terminal) { let cell_size = CellSize { width: d.grid_metrics.cell_width, height: d.grid_metrics.cell_height, }; update_kitty(&mut d.images, &mut st.terminal, cell_size); } } // Get our OSC8 links we're hovering if we have a mouse. // This requires terminal state because of URLs. let links = 'osc8: { // If our mouse isn't hovering, we have no links. let Some(vp) = st.mouse.point else { break 'osc8 HashSet::new(); }; // If the right mods aren't pressed, then we can't match. if !st .mouse .mods .equal(ghostty_input::ctrl_or_super(Mods::default())) { break 'osc8 HashSet::new(); } t.terminal_state.link_cells(&st.terminal.screen().pages, vp) }; Critical { links, mouse_point: st.mouse.point, mouse_mods: st.mouse.mods, preedit, scrollbar, overlay_features: st .inspector .as_ref() .map(|i| i.overlay_features()) .unwrap_or_default(), } }; // Outside the critical area we can update our links to contain // our regex results. if let Err(err) = t.config.links.render_cell_map( &mut critical.links, &t.terminal_state, critical.mouse_point, critical.mouse_mods, ) { log::warn!("error searching for regex links err={err}"); } // Clear our highlight state and update. if t.search_matches_dirty || t.terminal_state.dirty != Dirty::False { t.search_matches_dirty = false; // Clear the prior highlights let mut any_dirty = false; for row in &mut t.terminal_state.row_data { if !row.highlights.is_empty() { row.highlights.clear(); row.dirty = true; any_dirty = true; } } if any_dirty && t.terminal_state.dirty == Dirty::False { t.terminal_state.dirty = Dirty::Partial; } // NOTE: The order below matters. Highlights added earlier // will take priority. if let Some(m) = &t.search_selected_match { t.terminal_state.update_highlights_flattened( HighlightTag::SearchMatchSelected as u8, std::slice::from_ref(m), ); } if let Some(m) = &t.search_matches { t.terminal_state .update_highlights_flattened(HighlightTag::SearchMatch as u8, m); } } // Rebuild the overlay image if we have one. We can do this // outside of any critical areas. if let Err(err) = self.rebuild_overlay(t, &critical.overlay_features) { log::warn!("error rebuilding overlay surface err={err}"); } // Acquire the draw mutex for all remaining state updates. { let mut d = lock(&self.draw); // Build our GPU cells let cursor_style = cursor::style( &t.terminal_state, cursor::StyleOptions { preedit: critical.preedit.is_some(), focused: d.focused, blink_visible: cursor_blink_visible, }, ); d.rebuild_cells(t, critical.preedit.as_ref(), cursor_style, &critical.links); // The scrollbar is only emitted during draws so we also // check the scrollbar cache here and update if needed. // This is pretty fast. if d.scrollbar != critical.scrollbar { d.scrollbar = critical.scrollbar; d.scrollbar_dirty = true; } // Update our background color d.uniforms.bg_color = [ t.terminal_state.colors.background.r, t.terminal_state.colors.background.g, t.terminal_state.colors.background.b, (t.config.background_opacity * 255.0).round() as u8, ]; // If we're on macOS and have glass styles, we remove // the background opacity because the glass effect handles // it. #[cfg(target_os = "macos")] if matches!( t.config.background_blur, BackgroundBlur::MacosGlassRegular | BackgroundBlur::MacosGlassClear ) { d.uniforms.bg_color[3] = 0; } // Prepare our overlay image for upload (or unload). d.has_overlay = t.overlay.is_some(); if let Err(err) = d.images.overlay_update(t.overlay.as_ref()) { log::warn!("error updating overlay images err={err}"); } // Update custom shader uniforms that depend on terminal state. let cursor_text = t.config.cursor_text; d.update_custom_shader_uniforms_from_state(&t.terminal_state, cursor_text); } // Reset our dirty state after updating. t.terminal_state.dirty = Dirty::False; // Notify our shaper we're done for the frame. For some shapers, // such as CoreText, this triggers off-thread cleanup logic. t.font_shaper.end_frame(); Ok(()) } /// Build the overlay as configured (`rebuildOverlay`). fn rebuild_overlay( &self, t: &mut ThreadState, features: &[overlay::Feature], ) -> Result<(), overlay::InitError> { // If we have no features enabled, don't build an overlay. // If we had a previous overlay, deallocate it. if features.is_empty() { t.overlay = None; return Ok(()); } let size = lock(&self.draw).size; // If we had a previous overlay, clear it. Otherwise, init. let reuse = match &t.overlay { Some(v) => { // Verify that our overlay size matches our screen // size as we know it now. If not, deinit and reinit. let term_size = size.terminal(); // We also depend on cell size. v.width() == term_size.width && v.height() == term_size.height && v.cell_size == size.cell } None => false, }; if reuse { // Everything matches, so we can just reset the surface // and redraw. if let Some(v) = &mut t.overlay { v.reset(); } } else { // If we reached this point we want to reset our overlay. t.overlay = None; t.overlay = Some(Overlay::new(&size)?); } if let Some(v) = &mut t.overlay { v.apply_features(&t.terminal_state, features); } Ok(()) } } /// Update the kitty image state from the terminal (the `kittyUpdate` call in /// `updateFrame`). fn update_kitty( images: &mut image::State, terminal: &mut ghostty_terminal::terminal::Terminal, cell_size: CellSize, ) { let info = ghostty_terminal::kitty::graphics_storage::TerminalInfo::from_terminal(terminal); let screen = terminal.screen_mut(); images.kitty_update(&screen.kitty_images, &screen.pages, &info, cell_size); images.force_kitty_update = false; screen.kitty_images.dirty = false; } // --------------------------------------------------------------------------- // rebuild_cells (draw lock held, renderer thread) /// Read-only data shared by every row rebuild. struct RowEnv<'a> { config: &'a DerivedConfig, colors: &'a ghostty_terminal::render::Colors, cursor: &'a RenderCursor, cols: u16, } /// Which highlight (if any) covers a cell. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(clippy::enum_variant_names)] enum Selected { False, Selection, Search, SearchSelected, } impl DrawState { /// Convert the terminal state to GPU cells stored in CPU memory. These /// are then synced to the GPU in the next frame. This only updates CPU /// memory and doesn't touch the GPU. /// /// This requires the draw mutex. /// /// Dirty state on terminal state won't be reset by this. fn rebuild_cells( &mut self, t: &mut ThreadState, preedit: Option<&Preedit>, cursor_style_: Option, links: &HashSet, ) { let ThreadState { config, terminal_state: state, font_shaper, font_shaper_cache, run_cells, shaped_cells, .. } = t; let grid_size_diff = self.cells.size.rows != state.rows || self.cells.size.columns != state.cols; if grid_size_diff { let new_size = GridSize { rows: state.rows, columns: state.cols, }; self.cells.resize(new_size); // Update our uniforms accordingly, otherwise // our background cells will be out of place. self.uniforms.grid_size = [new_size.columns, new_size.rows]; } let rebuild = state.dirty == Dirty::Full || grid_size_diff; if rebuild { // If we are doing a full rebuild, then we clear the entire cell buffer. self.cells.reset(); // We also reset our padding extension depending on the screen type match config.padding_color { WindowPaddingColor::Background => {} // For extension, assume we are extending in all directions. // For "extend" this may be disabled due to heuristics below. WindowPaddingColor::Extend | WindowPaddingColor::ExtendAlways => { self.uniforms.padding_extend = PaddingExtend { up: true, down: true, left: true, right: true, } .to_bits(); } } } let RenderState { rows, cols, colors, cursor, row_data, .. } = &mut *state; let (rows, cols) = (*rows, *cols); // If our cell contents buffer is shorter than the screen viewport, // we render the rows that fit, starting from the bottom. If instead // the viewport is shorter than the cell contents buffer, we align // the top of the viewport with the top of the contents buffer. let row_len = usize::from(rows.min(self.cells.size.rows)); // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. let preedit_range: Option = preedit.and_then(|preedit_v| { // We base the preedit on the position of the cursor in the // viewport. If the cursor isn't visible in the viewport we // don't show it. let cursor_vp = cursor.viewport?; // If our preedit row isn't dirty then we don't need the // preedit range. This also avoids an issue later where we // unconditionally add preedit cells when this is set. if !rebuild && !row_data[usize::from(cursor_vp.y)].dirty { return None; } let range = preedit_v.range(cursor_vp.x, cols - 1); Some(PreeditRange { y: cursor_vp.y, x: [range.start, range.end], cp_offset: range.cp_offset, }) }); let env = RowEnv { config, colors, cursor, cols, }; let mut sh = ShapeCtx { shaper: font_shaper, cache: font_shaper_cache, run_cells, shaped: shaped_cells, }; for (y_usize, row) in row_data.iter_mut().enumerate().take(row_len) { let y = y_usize as u16; if !rebuild { // Only rebuild if we are doing a full rebuild or this row is dirty. if !row.dirty { continue; } // Clear the cells if the row is dirty self.cells.clear(y); } // Unmark the dirty state in our render state. row.dirty = false; if let Err(err) = self.rebuild_row(&env, y, row, preedit_range.as_ref(), links, &mut sh) { // This should never happen except under exceptional // scenarios. In this case, we don't want to corrupt // our render state so just clear this row and keep // trying to finish it out. log::warn!("error building row y={y} err={err}"); self.cells.clear(y); } } // Setup our cursor rendering information. self.setup_cursor(&env, row_data, cursor_style_, preedit.is_some()); // Setup our preedit text. if let Some(preedit_v) = preedit && let Some(range) = &preedit_range { let mut x = range.x[0]; for cp in &preedit_v.codepoints[range.cp_offset..] { if let Err(err) = self.add_preedit_cell( config, cp.codepoint, cp.wide, Coordinate { x, y: u32::from(range.y), }, colors.foreground, ) { log::warn!( "error building preedit cell, will be invalid x={x} y={}, err={err}", range.y ); } x += if cp.wide { 2 } else { 1 }; } } // Update that our cells rebuilt self.cells_rebuilt = true; } fn setup_cursor( &mut self, env: &RowEnv<'_>, row_data: &[RenderRow], cursor_style_: Option, has_preedit: bool, ) { let config = env.config; let colors = env.colors; // Clear our cursor by default. self.cells.set_cursor(None, None); self.uniforms.cursor_pos = [u16::MAX, u16::MAX]; // If the cursor isn't visible on the viewport, don't show // a cursor. Otherwise, get our cursor cell, because we may // need it for styling. let Some(cursor_vp) = env.cursor.viewport else { return; }; let cursor_style: Style = { let cell = row_data[usize::from(cursor_vp.y)] .cells .get(usize::from(cursor_vp.x)); if cell.raw.has_styling() { cell.style } else { Style::default() } }; // If we have preedit text, we don't setup a cursor if has_preedit { return; } // If there isn't a cursor visual style requested then // we don't render a cursor. let Some(style) = cursor_style_ else { return; }; let bold = bold_policy(config.bold_color); let fg_style = cursor_style.fg(style::Fg { default: colors.foreground, palette: &colors.palette, bold, }); let bg_style = cursor_style .bg(&env.cursor.cell, &colors.palette) .unwrap_or(colors.background); // Determine the cursor color. let cursor_color = 'color: { // If an explicit cursor color was set by OSC 12, use that. if let Some(v) = colors.cursor { break 'color v; } // Use our configured color if specified if let Some(v) = config.cursor_color { break 'color resolve_terminal_color( v, cursor_style.flags.inverse, Some(fg_style), Some(bg_style), ) .unwrap_or(colors.foreground); } colors.foreground }; self.add_cursor(env, style, cursor_color); // If the cursor is visible then we set our uniforms. if style == CursorStyle::Block { let wide = env.cursor.cell.wide(); self.uniforms.cursor_pos = [ // If we are a spacer tail of a wide cell, our cursor needs // to move back one cell. The saturate is to ensure we don't // overflow but this shouldn't happen with well-formed input. match wide { Wide::Narrow | Wide::SpacerHead | Wide::Wide => cursor_vp.x, Wide::SpacerTail => cursor_vp.x.saturating_sub(1), }, cursor_vp.y, ]; let mut bools = Bools::from_bits(self.uniforms.bools); bools.cursor_wide = match wide { Wide::Narrow | Wide::SpacerHead => false, Wide::Wide | Wide::SpacerTail => true, }; self.uniforms.bools = bools.to_bits(); let uniform_color = if let Some(txt) = config.cursor_text { // If cursor-text is set, then compute the correct color. // Otherwise, use the background color. resolve_terminal_color( txt, cursor_style.flags.inverse, Some(fg_style), Some(bg_style), ) .unwrap_or(colors.background) } else { colors.background }; self.uniforms.cursor_color = [uniform_color.r, uniform_color.g, uniform_color.b, 255]; } } } impl DrawState { #[allow(clippy::too_many_arguments)] fn rebuild_row( &mut self, env: &RowEnv<'_>, y: u16, row: &RenderRow, preedit_range: Option<&PreeditRange>, links: &HashSet, sh: &mut ShapeCtx<'_>, ) -> Result<(), Error> { let config = env.config; let colors = env.colors; let cursor = env.cursor; // If our viewport is wider than our cell contents buffer, // we still only process cells up to the width of the buffer. let cells = &row.cells; let cells_len = cells.len().min(usize::from(self.cells.size.columns)); let cells_raw = &cells.raw; // On primary screen, we still apply vertical padding // extension under certain conditions we feel are safe. // // This helps make some scenarios look better while // avoiding scenarios we know do NOT look good. match config.padding_color { // These already have the correct values set above. WindowPaddingColor::Background | WindowPaddingColor::ExtendAlways => {} // Apply heuristics for padding extension. WindowPaddingColor::Extend => { if y == 0 { let mut pe = PaddingExtend::from_bits(self.uniforms.padding_extend); pe.up = !never_extend_bg( row.raw, cells_raw, |x| cells.style(x), &colors.palette, colors.background, ); self.uniforms.padding_extend = pe.to_bits(); } else if y == self.cells.size.rows - 1 { let mut pe = PaddingExtend::from_bits(self.uniforms.padding_extend); pe.down = !never_extend_bg( row.raw, cells_raw, |x| cells.style(x), &colors.palette, colors.background, ); self.uniforms.padding_extend = pe.to_bits(); } } } // The run iterator wants an owned-cell slice (`RunCell`). sh.run_cells.clear(); sh.run_cells.extend(cells.iter().map(|c| RunCell { raw: c.raw, grapheme: c.grapheme.to_vec(), style: c.style, })); // Iterator of runs for shaping. let font_grid = Arc::clone(&self.font_grid); let mut run_iter_opts = RunOptions::new(&font_grid, sh.run_cells.as_slice()); run_iter_opts.selection = row.selection; // We want to do font shaping as long as the cursor is // visible on this viewport. run_iter_opts.cursor_x = cursor .viewport .filter(|vp| vp.y == y) .map(|vp| usize::from(vp.x)); run_iter_opts.apply_break_config(config.font_shaping_break); let mut run_iter = RunIterator::new(run_iter_opts); let mut shaper_run: Option = run_iter.next(&mut *sh.shaper)?; // `sh.shaped` holds the shaped cells of `shaper_run` when `have_cells`. let mut have_cells = false; let mut shaper_cells_i: usize = 0; for x in 0..cells_len { // If this cell falls within our preedit range then we // skip this because preedits are setup separately. if let Some(range) = preedit_range && range.y == y && x >= usize::from(range.x[0]) { // We're in the preedit range, skip this cell. if x <= usize::from(range.x[1]) { continue; } // After exiting the preedit range we need to catch // the run position up because of the missed cells. // In all other cases, no action is necessary. if x == usize::from(range.x[1]) + 1 { // Step the run iterator until we find a run that ends // after the current cell, which will be the soonest run // that might contain glyphs for our cell. while let Some(run) = shaper_run { if usize::from(run.offset) + usize::from(run.cells) > x { break; } shaper_run = run_iter.next(&mut *sh.shaper)?; have_cells = false; shaper_cells_i = 0; } if let Some(run) = shaper_run { // If we haven't shaped this run, do so now. if !have_cells { shaped_cells_for_run(sh.shaper, sh.cache, sh.shaped, &font_grid, &run)?; have_cells = true; } // Advance our index until we reach or pass // our current x position in the shaper cells. while shaper_cells_i < sh.shaped.len() && usize::from(run.offset) + usize::from(sh.shaped[shaper_cells_i].x) < x { shaper_cells_i += 1; } } } } let cell = cells_raw[x]; let wide = cell.wide(); let cell_style: Style = if cell.has_styling() { cells.style(x) } else { Style::default() }; // True if this cell is selected let selected = 'selected: { // Order below matters for precedence. // Selection should take the highest precedence. let x_compare = if wide == Wide::SpacerTail { x.saturating_sub(1) } else { x }; if let Some(sel) = row.selection && x_compare >= usize::from(sel[0]) && x_compare <= usize::from(sel[1]) { break 'selected Selected::Selection; } // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for hl in &row.highlights { if x_compare >= usize::from(hl.range[0]) && x_compare <= usize::from(hl.range[1]) { break 'selected if hl.tag == HighlightTag::SearchMatchSelected as u8 { Selected::SearchSelected } else { Selected::Search }; } } Selected::False }; // The `_style` suffixed values are the colors based on // the cell style (SGR), before applying any additional // configuration, inversions, selections, etc. let bg_style = cell_style.bg(&cell, &colors.palette); let fg_style = cell_style.fg(style::Fg { default: colors.foreground, palette: &colors.palette, bold: bold_policy(config.bold_color), }); let inverse = cell_style.flags.inverse; // The final background color for the cell. let bg: Option = match selected { // If we have an explicit selection background color // specified in the config, use that. // // If no configuration, then our selection background // is our foreground color. Selected::Selection => match config.selection_background { Some(v) => resolve_terminal_color(v, inverse, Some(fg_style), bg_style), None => Some(colors.foreground), }, Selected::Search => resolve_terminal_color( config.search_background, inverse, Some(fg_style), bg_style, ), Selected::SearchSelected => resolve_terminal_color( config.search_selected_background, inverse, Some(fg_style), bg_style, ), // Not selected Selected::False => { if inverse == cell::is_covering(cell.codepoint()) { // Otherwise they cancel out. bg_style } else { // Two cases cause us to invert (use the fg color as the bg) // - The "inverse" style flag. // - A "covering" glyph; we use fg for bg in that // case to help make sure that padding extension // works correctly. // // If one of these is true (but not the other) // then we use the fg style color for the bg. Some(fg_style) } } }; let fg: Rgb = { // Our happy-path non-selection background color // is our style or our configured defaults. let final_bg = bg_style.unwrap_or(colors.background); // Whether we need to use the bg color as our fg color: // - Cell is selected, inverted, and set to cell-foreground // - Cell is selected, not inverted, and set to cell-background // - Cell is inverted and not selected match selected { Selected::Selection => match config.selection_foreground { Some(v) => { resolve_terminal_color(v, inverse, Some(fg_style), Some(final_bg)) } None => Some(colors.background), }, Selected::Search => resolve_terminal_color( config.search_foreground, inverse, Some(fg_style), Some(final_bg), ), Selected::SearchSelected => resolve_terminal_color( config.search_selected_foreground, inverse, Some(fg_style), Some(final_bg), ), Selected::False => Some(if inverse { final_bg } else { fg_style }), } .unwrap_or(fg_style) }; // Foreground alpha for this cell. let alpha: u8 = if cell_style.flags.faint { config.faint_opacity } else { 255 }; // Set the cell's background color. { let rgb = bg.unwrap_or(colors.background); // Determine our background alpha. If we have transparency configured // then this is dynamic depending on some situations. This is all // in an attempt to make transparency look the best for various // situations. See inline comments. let bg_alpha: u8 = 'bg_alpha: { let default: u8 = 255; // Cells that are selected should be fully opaque. if selected != Selected::False { break 'bg_alpha default; } // Cells that are reversed should be fully opaque. if inverse { break 'bg_alpha default; } // If the user requested to have opacity on all cells, apply it. if config.background_opacity_cells && bg_style.is_some() { let mut opacity = f64::from(default); opacity *= config.background_opacity; break 'bg_alpha opacity as u8; } // Cells that have an explicit bg color should be fully opaque. if bg_style.is_some() { break 'bg_alpha default; } // Otherwise, we won't draw the bg for this cell, // we'll let the already-drawn background color // show through. 0 }; *self.cells.bg_cell(usize::from(y), x) = [rgb.r, rgb.g, rgb.b, bg_alpha]; } // If the invisible flag is set on this cell then we // don't need to render any foreground elements, so // we just skip all glyphs with this x coordinate. // // NOTE: This behavior matches xterm. Some other terminal // emulators, e.g. Alacritty, still render text decorations // and only make the text itself invisible. The decision // has been made here to match xterm's behavior for this. if cell_style.flags.invisible { continue; } // Give links a single underline, unless they already have // an underline, in which case use a double underline to // distinguish them. let underline = if links.contains(&Coordinate { x: x as u16, y: u32::from(y), }) { if cell_style.flags.underline == Underline::Single { Underline::Double } else { Underline::Single } } else { cell_style.flags.underline }; // We draw underlines first so that they layer underneath text. // This improves readability when a colored underline is used // which intersects parts of the text (descenders). if underline != Underline::None && let Err(err) = self.add_underline( x as u16, y, underline, cell_style.underline_color(&colors.palette).unwrap_or(fg), alpha, ) { log::warn!( "error adding underline to cell, will be invalid x={x} y={y}, err={err}" ); } if cell_style.flags.overline && let Err(err) = self.add_overline(x as u16, y, fg, alpha) { log::warn!("error adding overline to cell, will be invalid x={x} y={y}, err={err}"); } // If we're at or past the end of our shaper run then // we need to get the next run from the run iterator. if have_cells && shaper_cells_i >= sh.shaped.len() { shaper_run = run_iter.next(&mut *sh.shaper)?; have_cells = false; shaper_cells_i = 0; } if let Some(run) = shaper_run { // If we haven't shaped this run yet, do so. if !have_cells { shaped_cells_for_run(sh.shaper, sh.cache, sh.shaped, &font_grid, &run)?; have_cells = true; } // If there are no shaper cells for this run, ignore it. // This can occur for runs of empty cells, and is fine. if !sh.shaped.is_empty() && shaper_cells_i < sh.shaped.len() { // If we encounter a shaper cell to the left of the current // cell then we have some problems. This logic relies on x // position monotonically increasing. debug_assert!( usize::from(run.offset) + usize::from(sh.shaped[shaper_cells_i].x) >= x ); // NOTE: An assumption is made here that a single cell will never // be present in more than one shaper run. If that assumption is // violated, this logic breaks. while shaper_cells_i < sh.shaped.len() && usize::from(run.offset) + usize::from(sh.shaped[shaper_cells_i].x) == x { let shaped_cell = sh.shaped[shaper_cells_i]; if let Err(err) = self.add_glyph( config, x as u16, y, usize::from(env.cols), cells_raw, shaped_cell, &run, fg, alpha, ) { log::warn!( "error adding glyph to cell, will be invalid x={x} y={y}, err={err}" ); } shaper_cells_i += 1; } } } // Finally, draw a strikethrough if necessary. if cell_style.flags.strikethrough && let Err(err) = self.add_strikethrough(x as u16, y, fg, alpha) { log::warn!( "error adding strikethrough to cell, will be invalid x={x} y={y}, err={err}" ); } } Ok(()) } /// Render options for a sprite glyph that spans a single cell. fn sprite_options(&self, cell_width: u8) -> RenderOptions { RenderOptions { cell_width: Some(cell_width), ..RenderOptions::new(self.grid_metrics) } } /// Add a sprite decoration (underline, overline, strikethrough) to the /// specified cell. fn add_sprite_decoration( &mut self, key: CellKey, sprite: Sprite, x: u16, y: u16, color: Rgb, alpha: u8, ) -> Result<(), Error> { let render = self.font_grid .render_glyph(SPRITE_INDEX, sprite as u32, self.sprite_options(1))?; self.cells.add( key, CellText { atlas: CellTextAtlas::Grayscale as u8, grid_pos: [x, y], color: [color.r, color.g, color.b, alpha], glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y], glyph_size: [render.glyph.width, render.glyph.height], bearings: [render.glyph.offset_x as i16, render.glyph.offset_y as i16], ..CellText::default() }, ); Ok(()) } /// Add an underline decoration to the specified cell fn add_underline( &mut self, x: u16, y: u16, style: Underline, color: Rgb, alpha: u8, ) -> Result<(), Error> { let sprite = match style { Underline::None => unreachable!(), Underline::Single => Sprite::Underline, Underline::Double => Sprite::UnderlineDouble, Underline::Dotted => Sprite::UnderlineDotted, Underline::Dashed => Sprite::UnderlineDashed, Underline::Curly => Sprite::UnderlineCurly, }; self.add_sprite_decoration(CellKey::Underline, sprite, x, y, color, alpha) } /// Add a overline decoration to the specified cell fn add_overline(&mut self, x: u16, y: u16, color: Rgb, alpha: u8) -> Result<(), Error> { self.add_sprite_decoration(CellKey::Overline, Sprite::Overline, x, y, color, alpha) } /// Add a strikethrough decoration to the specified cell fn add_strikethrough(&mut self, x: u16, y: u16, color: Rgb, alpha: u8) -> Result<(), Error> { self.add_sprite_decoration( CellKey::Strikethrough, Sprite::Strikethrough, x, y, color, alpha, ) } /// Add a glyph to the specified cell. #[allow(clippy::too_many_arguments)] fn add_glyph( &mut self, config: &DerivedConfig, x: u16, y: u16, cols: usize, cell_raws: &[PageCell], shaper_cell: shape::Cell, shaper_run: &TextRun, color: Rgb, alpha: u8, ) -> Result<(), Error> { let cell = cell_raws[usize::from(x)]; let cp = cell.codepoint(); // Render let render = self.font_grid.render_glyph( shaper_run.font_index, shaper_cell.glyph_index, RenderOptions { thicken: config.font_thicken, thicken_strength: config.font_thicken_strength, cell_width: Some(cell.grid_width()), // If there's no Nerd Font constraint for this codepoint // then, if it's a symbol, we constrain it to fit inside // its cell(s), we don't modify the alignment at all. constraint: get_constraint(cp).unwrap_or(if cell::is_symbol(cp) { Constraint { size: ConstraintSize::Fit, ..Constraint::NONE } } else { Constraint::NONE }), constraint_width: cell::constraint_width(cell_raws, usize::from(x), cols), ..RenderOptions::new(self.grid_metrics) }, )?; // If the glyph is 0 width or height, it will be invisible // when drawn, so don't bother adding it to the buffer. if render.glyph.width == 0 || render.glyph.height == 0 { return Ok(()); } self.cells.add( CellKey::Text, CellText { atlas: match render.presentation { Presentation::Emoji => CellTextAtlas::Color as u8, Presentation::Text => CellTextAtlas::Grayscale as u8, }, bools: if cell::no_min_contrast(cp) { CellText::NO_MIN_CONTRAST } else { 0 }, grid_pos: [x, y], color: [color.r, color.g, color.b, alpha], glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y], glyph_size: [render.glyph.width, render.glyph.height], bearings: [ (render.glyph.offset_x + i32::from(shaper_cell.x_offset)) as i16, (render.glyph.offset_y + i32::from(shaper_cell.y_offset)) as i16, ], ..CellText::default() }, ); Ok(()) } fn add_cursor(&mut self, env: &RowEnv<'_>, cursor_style: CursorStyle, cursor_color: Rgb) { let cursor_state = env.cursor; let Some(cursor_vp) = cursor_state.viewport else { return; }; // Add the cursor. We render the cursor over the wide character if // we're on the wide character tail. let (wide, x) = if cursor_vp.wide_tail { // If we're part of a wide character, we move the cursor back // to the actual character. (true, cursor_vp.x.saturating_sub(1)) } else { // The cursor goes over the screen cursor position. (cursor_state.cell.wide() == Wide::Wide, cursor_vp.x) }; let alpha: u8 = if self.focused { (255.0 * env.config.cursor_opacity).ceil() as u8 } else { 255 }; let render = match cursor_style { CursorStyle::Block | CursorStyle::BlockHollow | CursorStyle::Bar | CursorStyle::Underline => { let sprite = match cursor_style { CursorStyle::Block => Sprite::CursorRect, CursorStyle::BlockHollow => Sprite::CursorHollowRect, CursorStyle::Bar => Sprite::CursorBar, CursorStyle::Underline => Sprite::CursorUnderline, CursorStyle::Lock => unreachable!(), }; match self.font_grid.render_glyph( SPRITE_INDEX, sprite as u32, self.sprite_options(if wide { 2 } else { 1 }), ) { Ok(r) => r, Err(err) => { log::warn!("error rendering cursor glyph err={err}"); return; } } } CursorStyle::Lock => { match self.font_grid.render_codepoint( 0xF023, // lock symbol FontStyle::Regular, Some(Presentation::Text), self.sprite_options(if wide { 2 } else { 1 }), ) { Ok(Some(r)) => r, Ok(None) => { // This should never happen because we embed nerd // fonts so we just log and return instead of fallback. log::warn!("failed to find lock symbol for cursor codepoint=0xF023"); return; } Err(err) => { log::warn!("error rendering cursor glyph err={err}"); return; } } } }; self.cells.set_cursor( Some(CellText { atlas: CellTextAtlas::Grayscale as u8, bools: CellText::IS_CURSOR_GLYPH, grid_pos: [x, cursor_vp.y], color: [cursor_color.r, cursor_color.g, cursor_color.b, alpha], glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y], glyph_size: [render.glyph.width, render.glyph.height], bearings: [render.glyph.offset_x as i16, render.glyph.offset_y as i16], ..CellText::default() }), Some(cursor_style), ); } fn add_preedit_cell( &mut self, config: &DerivedConfig, codepoint: u32, wide: bool, coord: Coordinate, screen_fg: Rgb, ) -> Result<(), Error> { let _ = config; // Render the glyph for our preedit text let render = match self.font_grid.render_codepoint( codepoint, FontStyle::Regular, Some(Presentation::Text), RenderOptions::new(self.grid_metrics), ) { Ok(Some(r)) => r, Ok(None) => { log::warn!("failed to find font for preedit codepoint={codepoint:X}"); return Ok(()); } Err(err) => { log::warn!("error rendering preedit glyph err={err}"); return Ok(()); } }; // Add our text self.cells.add( CellKey::Text, CellText { atlas: CellTextAtlas::Grayscale as u8, grid_pos: [coord.x, coord.y as u16], color: [screen_fg.r, screen_fg.g, screen_fg.b, 255], glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y], glyph_size: [render.glyph.width, render.glyph.height], bearings: [render.glyph.offset_x as i16, render.glyph.offset_y as i16], ..CellText::default() }, ); // Add underline self.add_underline(coord.x, coord.y as u16, Underline::Single, screen_fg, 255)?; if wide && coord.x < self.cells.size.columns - 1 { self.add_underline( coord.x + 1, coord.y as u16, Underline::Single, screen_fg, 255, )?; } Ok(()) } } // --------------------------------------------------------------------------- // draw_frame (GL thread) impl Renderer { /// Draw the frame to the screen. /// /// If `sync` is true, this will synchronously block until /// the frame is finished drawing and has been presented. /// /// Must be called with the GL context current (for GTK: from the /// `GtkGLArea` `render` signal, on the main thread). pub fn draw_frame(&self, sync: bool) -> Result<(), Error> { // We hold a the draw mutex to prevent changes to any // data we access while we're in the middle of drawing. let mut d = lock(&self.draw); let result = self.draw_frame_locked(&mut d, sync); // After the graphics API is complete (so we defer) we want to // update our scrollbar state. if d.scrollbar_dirty { // Fail instantly if the surface mailbox if full, we'll just // get it on the next frame. if self.host.scrollbar(d.scrollbar) { d.scrollbar_dirty = false; } } // Let our graphics API do any bookkeeping, etc. // that it needs to do before / after `draw_frame`. d.api.draw_frame_end(); result } fn draw_frame_locked(&self, d: &mut DrawState, sync: bool) -> Result<(), Error> { d.api.draw_frame_start(); // Retrieve the most up-to-date surface size from the Graphics API let (surface_width, surface_height) = d.api.surface_size()?; // If either of our surface dimensions is zero // then drawing is absurd, so we just return. if surface_width == 0 || surface_height == 0 { return Ok(()); } let size_changed = d.size.screen.width != surface_width || d.size.screen.height != surface_height; // Conditions under which we need to draw the frame, otherwise we // don't need to since the previous frame should be identical. let needs_redraw = size_changed || d.cells_rebuilt || self.has_animations() || sync; if !needs_redraw { // We still need to present the last target again, because the // apprt may be swapping buffers and display an outdated frame // if we don't draw something new. d.api.present_last_target()?; return Ok(()); } d.cells_rebuilt = false; // Get a frame state to draw to. let (idx, mut frame) = d.swap_chain.next_frame()?; let result = d.draw_with_frame(&mut frame, (surface_width, surface_height), size_changed); let frame_ctx = match result { Ok(frame_ctx) => frame_ctx, Err(err) => { // Nothing was submitted: give the frame state back. d.swap_chain.release_frame(idx, frame); return Err(err); } }; self.has_animations .store(d.has_custom_shaders, Ordering::Relaxed); // Complete the frame. The frame state is handed back to the swap // chain, and the health reported, when the frame completes // (`frameCompleted`): inline for synchronous frames, from the // graphics API's completion thread otherwise. let report = self.frame_completion(); let swap_chain = d.swap_chain.releaser(); let pending = Mutex::new(Some(frame)); let completion: FrameCompletion = Arc::new(move |health| { report(health); if let Some(frame) = lock(&pending).take() { SwapChainInner::release(&swap_chain, idx, frame); } }); frame_ctx.complete(sync, completion); Ok(()) } } impl DrawState { /// Encode a frame into `frame`'s buffers and the swap chain's targets. /// The returned graphics API frame has not been completed yet. fn draw_with_frame( &mut self, frame: &mut FrameState, surface_size: (u32, u32), size_changed: bool, ) -> Result { let (surface_width, surface_height) = surface_size; // If we need to reinitialize our shaders, do so. if self.reinitialize_shaders { self.reinitialize_shaders = false; self.shaders = None; self.init_shaders()?; } // Our shaders should not be defunct at this point. if self.shaders.is_none() { return Err(Error::Defunct); } // If we have custom shaders, make sure we have the // custom shader state in our frame state, otherwise // if we have a state but don't need it we remove it. if self.has_custom_shaders { if frame.custom_shader_state.is_none() { let mut state = CustomShaderState::::new(&self.api)?; state.resize(&self.api, surface_width as usize, surface_height as usize)?; frame.custom_shader_state = Some(state); } } else { frame.custom_shader_state = None; } // If our stored size doesn't match the // surface size we need to update it. if size_changed { self.size.screen = crate::size::ScreenSize { width: surface_width, height: surface_height, }; self.update_screen_size_uniforms(); } // If this frame's target isn't the correct size, or the target // config has changed (such as when the blending mode changes), // remove it and replace it with a new one with the right values. if frame.target.width() != self.size.screen.width as usize || frame.target.height() != self.size.screen.height as usize || frame.target_config_modified != self.target_config_modified { frame.resize( &self.api, self.size.screen.width as usize, self.size.screen.height as usize, )?; frame.target_config_modified = self.target_config_modified; // The remembered last target (if any) is the old one. self.api.clear_last_target(); } { let gpu = Gpu { api: &self.api }; // Upload images to the GPU as necessary. let _ = self.images.upload(&gpu); // Upload the background image to the GPU as necessary. upload_background_image(&mut self.bg_image, &gpu); } // Update per-frame custom shader uniforms. self.update_custom_shader_uniforms_for_frame(); // Setup our frame data frame.uniforms.sync(&[G::gpu_uniforms(&self.uniforms)])?; frame.cells_bg.sync(&self.cells.bg_cells)?; let fg_lists: Vec<&[CellText]> = self.cells.fg_rows.iter().map(Vec::as_slice).collect(); let fg_count = frame.cells.sync_from_slices(&fg_lists)?; drop(fg_lists); // If our background image buffer has changed, sync it. if frame.bg_image_buffer_modified != self.bg_image_buffer_modified { frame.bg_image_buffer.sync(&[self.bg_image_buffer])?; frame.bg_image_buffer_modified = self.bg_image_buffer_modified; } // If our font atlas changed, sync the texture data { let grid = Arc::clone(&self.font_grid); let modified = grid.read().atlas_grayscale.modified.load(Ordering::Relaxed); if modified > frame.grayscale_modified { let guard = grid.read(); frame.grayscale_modified = guard.atlas_grayscale.modified.load(Ordering::Relaxed); DrawState::sync_atlas_texture( &self.api, &guard.atlas_grayscale, &mut frame.grayscale, )?; } let modified = grid.read().atlas_color.modified.load(Ordering::Relaxed); if modified > frame.color_modified { let guard = grid.read(); frame.color_modified = guard.atlas_color.modified.load(Ordering::Relaxed); DrawState::sync_atlas_texture(&self.api, &guard.atlas_color, &mut frame.color)?; } } // Sync the custom shader uniforms now while we can still use `self`. if let Some(state) = &mut frame.custom_shader_state { state.uniforms.sync(&[self.custom_shader_uniforms])?; } // Get a frame context from the graphics API. let DrawState { api, shaders, images, bg_image, has_overlay, .. } = &*self; let Some(shaders) = shaders.as_ref() else { return Err(Error::Defunct); }; let gpu = Gpu { api }; let frame_ctx = api.begin_frame(Arc::clone(&frame.target)); { let attachments = [Attachment { target: match &frame.custom_shader_state { Some(state) => AttachmentTarget::Texture(&state.back_texture), None => AttachmentTarget::Target(&*frame.target), }, clear_color: Some([0.0, 0.0, 0.0, 0.0]), }]; let mut pass = frame_ctx.render_pass(&attachments); // First we draw our background image, if we have one. // The bg image shader also draws the main bg color. // // Otherwise, if we don't have a background image, we // draw the background color by itself in its own step. // // NOTE: We don't use the clear_color for this because that // would require us to do color space conversion on the // CPU-side. In the future when we have utilities for // that we should remove this step and use clear_color. match bg_image { Some(img) => { if let image::Image::Ready(texture) = img { pass.step(&Step { pipeline: shaders.bg_image(), uniforms: Some(frame.uniforms.raw()), buffers: &[Some(frame.bg_image_buffer.raw())], textures: &[Some(texture)], samplers: &[], draw: Draw::new(Primitive::Triangle, 3), }); } } None => { pass.step(&Step { pipeline: shaders.bg_color(), uniforms: Some(frame.uniforms.raw()), buffers: &[None, Some(frame.cells_bg.raw())], textures: &[], samplers: &[], draw: Draw::new(Primitive::Triangle, 3), }); } } // Then we draw any kitty images that need // to be behind text AND cell backgrounds. images.draw( &gpu, shaders.image(), &mut pass, image::DrawPlacements::KittyBelowBg, ); // Then we draw any opaque cell backgrounds. pass.step(&Step { pipeline: shaders.cell_bg(), uniforms: Some(frame.uniforms.raw()), buffers: &[None, Some(frame.cells_bg.raw())], textures: &[], samplers: &[], draw: Draw::new(Primitive::Triangle, 3), }); // Kitty images between cell backgrounds and text. images.draw( &gpu, shaders.image(), &mut pass, image::DrawPlacements::KittyBelowText, ); // Text. pass.step(&Step { pipeline: shaders.cell_text(), uniforms: Some(frame.uniforms.raw()), buffers: &[Some(frame.cells.raw()), Some(frame.cells_bg.raw())], textures: &[Some(&frame.grayscale), Some(&frame.color)], samplers: &[], draw: Draw { ty: Primitive::TriangleStrip, vertex_count: 4, instance_count: fg_count, }, }); // Kitty images in front of text. images.draw( &gpu, shaders.image(), &mut pass, image::DrawPlacements::KittyAboveText, ); // Debug overlay. We do this before any custom shader state // because our debug overlay is aligned with the grid. if *has_overlay { images.draw( &gpu, shaders.image(), &mut pass, image::DrawPlacements::Overlay, ); } pass.complete(); } // If we have custom shaders, then we render them. if let Some(state) = &mut frame.custom_shader_state { let post_count = shaders.post_pipelines().len(); for (i, pipeline) in shaders.post_pipelines().iter().enumerate() { { let attachments = [Attachment { target: if i < post_count - 1 { AttachmentTarget::Texture(&state.front_texture) } else { AttachmentTarget::Target(&*frame.target) }, clear_color: Some([0.0, 0.0, 0.0, 0.0]), }]; let mut pass = frame_ctx.render_pass(&attachments); pass.step(&Step { pipeline, uniforms: Some(state.uniforms.raw()), buffers: &[], textures: &[Some(&state.back_texture)], samplers: &[Some(&state.sampler)], draw: Draw::new(Primitive::Triangle, 3), }); pass.complete(); } state.swap(); } } // The caller completes the frame (glFinish, error check, present). Ok(frame_ctx) } } #[cfg(test)] mod tests { use ghostty_config::types_a::Color as ConfigColor; use super::*; fn rgb(r: u8, g: u8, b: u8) -> Rgb { Rgb { r, g, b } } #[test] fn derived_config_from_default_config() { let config = Config::default(); let d = DerivedConfig::new(&config).unwrap(); // The default config installs the URL link. assert!(!d.links.links.is_empty()); assert_eq!(d.background, config.background.to_terminal_rgb()); assert_eq!(d.foreground, config.foreground.to_terminal_rgb()); assert!((d.background_opacity - 1.0).abs() < f64::EPSILON); // faint-opacity 0.5 -> ceil(127.5) = 128 assert_eq!(d.faint_opacity, 128); assert_eq!(d.font_styles, [true; 4]); assert_eq!(d.custom_shaders.len(), 0); assert!(d.bg_image.is_none()); assert!(!d.scroll_to_bottom_on_output); } #[test] fn derived_config_clamps_opacity() { let config = Config { background_opacity: 3.0, cursor_opacity: -1.0, ..Config::default() }; let d = DerivedConfig::new(&config).unwrap(); assert!((d.background_opacity - 1.0).abs() < f64::EPSILON); assert!(d.cursor_opacity.abs() < f64::EPSILON); } #[test] fn terminal_color_resolution() { let fg = Some(rgb(1, 1, 1)); let bg = Some(rgb(2, 2, 2)); let explicit = TerminalColor::Color(ConfigColor { r: 9, g: 9, b: 9 }); assert_eq!( resolve_terminal_color(explicit, false, fg, bg), Some(rgb(9, 9, 9)) ); assert_eq!( resolve_terminal_color(TerminalColor::CellForeground, false, fg, bg), fg ); assert_eq!( resolve_terminal_color(TerminalColor::CellForeground, true, fg, bg), bg ); assert_eq!( resolve_terminal_color(TerminalColor::CellBackground, false, fg, bg), bg ); assert_eq!( resolve_terminal_color(TerminalColor::CellBackground, true, fg, bg), fg ); } #[test] fn bold_color_policy() { assert!(bold_policy(None).is_none()); assert!(matches!( bold_policy(Some(BoldColor::Bright)), Some(style::BoldColor::Bright) )); let c = ConfigColor { r: 1, g: 2, b: 3 }; assert!(matches!( bold_policy(Some(BoldColor::Color(c))), Some(style::BoldColor::Color(Rgb { r: 1, g: 2, b: 3 })) )); } #[test] fn file_type_detection() { assert_eq!( detect_file_type(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0]), FileType::Png ); assert_eq!(detect_file_type(&[0xFF, 0xD8, 0xFF, 0xE0]), FileType::Jpeg); assert_eq!(detect_file_type(b"GIF89a.."), FileType::Gif); assert_eq!(detect_file_type(b"hello"), FileType::Unknown); assert_eq!( guess_file_type_from_extension("/a/b.JPG".as_ref()), FileType::Jpeg ); assert_eq!( guess_file_type_from_extension("/a/b.png".as_ref()), FileType::Png ); assert_eq!( guess_file_type_from_extension("/a/b".as_ref()), FileType::Unknown ); } #[test] fn background_image_png_roundtrip() { // Encode a 2x1 RGBA png, write it to disk and load it back. let mut bytes = Vec::new(); { let mut enc = png::Encoder::new(&mut bytes, 2, 1); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); let mut w = enc.write_header().unwrap(); w.write_image_data(&[1, 2, 3, 255, 4, 5, 6, 128]).unwrap(); } let dir = std::env::temp_dir().join(format!("ghostty-bg-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("bg.png"); std::fs::write(&path, &bytes).unwrap(); let img = load_background_image(&path).expect("decoded"); assert_eq!((img.width, img.height), (2, 1)); assert_eq!(img.data, [1, 2, 3, 255, 4, 5, 6, 128]); // Missing / undecodable files are not an error, just no image. assert!(load_background_image(&dir.join("missing.png")).is_none()); let bad = dir.join("bad.txt"); std::fs::write(&bad, b"not an image").unwrap(); assert!(load_background_image(&bad).is_none()); std::fs::remove_dir_all(&dir).unwrap(); } }