use log::*;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use wgpu::{CurrentSurfaceTexture, VertexBufferLayout, VertexStepMode};
use winit::window::Window;
use crate::terminal::cell::{
ATTR_BOLD, ATTR_DIM, ATTR_HIDDEN, ATTR_INVERSE, ATTR_ITALIC, CellColor, color_rgb,
is_emoji_modifier, resolve_bg, resolve_color,
};
use crate::terminal::{Cell, CursorStyle};
use crate::terminal::{Grid, Scrollback, Selection};
const CURSOR_VS: &str = r#"
struct VertInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>,
}
struct VertOutput {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
}
@vertex
fn vs_main(input: VertInput) -> VertOutput {
var out: VertOutput;
out.position = vec4<f32>(input.position, 0.0, 1.0);
out.color = input.color;
return out;
}
"#;
const CURSOR_FS: &str = r#"
struct FragInput {
@location(0) color: vec4<f32>,
}
@fragment
fn fs_main(input: FragInput) -> @location(0) vec4<f32> {
return input.color;
}
"#;
const BACKGROUND_VS: &str = r#"
struct VertInput {
@location(0) position: vec2<f32>,
@location(1) uv: vec2<f32>,
@location(2) opacity: f32,
}
struct VertOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) opacity: f32,
}
@vertex
fn vs_main(input: VertInput) -> VertOutput {
var out: VertOutput;
out.position = vec4<f32>(input.position, 0.0, 1.0);
out.uv = input.uv;
out.opacity = input.opacity;
return out;
}
"#;
const BACKGROUND_FS: &str = r#"
struct FragInput {
@location(0) uv: vec2<f32>,
@location(1) opacity: f32,
}
@group(0) @binding(0) var bg_tex: texture_2d<f32>;
@group(0) @binding(1) var bg_sampler: sampler;
@fragment
fn fs_main(input: FragInput) -> @location(0) vec4<f32> {
let color = textureSample(bg_tex, bg_sampler, input.uv);
return vec4<f32>(color.rgb, color.a * input.opacity);
}
"#;
const RECT_FLOATS_PER_VERTEX: usize = 6;
const RECT_VERTICES_PER_QUAD: usize = 6;
const INITIAL_RECT_VERTEX_BYTES: u64 = 64 * 1024;
const RECT_VERTEX_BUFFER_LABEL: &str = "rect_vertices";
const BG_FLOATS_PER_VERTEX: usize = 5;
const BG_VERTICES_PER_QUAD: usize = 6;
const BACKGROUND_VERTEX_BUFFER_BYTES: u64 =
(BG_FLOATS_PER_VERTEX * BG_VERTICES_PER_QUAD * std::mem::size_of::<f32>()) as u64;
const DEFAULT_FOREGROUND: u32 = 0xE6E6E6;
const DEFAULT_BACKGROUND: u32 = 0x02050A;
const SELECTION_FG: u32 = 0xBFBFBF;
const SELECTION_BG: u32 = 0x0066CC;
const SELECTION_BG_ALPHA: f32 = 0.82;
const DEFAULT_INVERSE_BG: u32 = 0x0066CC;
const CURSOR_BLOCK_BG: u32 = 0x00A2FF;
const CURSOR_BLOCK_FG: u32 = 0x02050A;
const CURSOR_THIN_BG: u32 = 0x00A2FF;
const HYPERLINK_BG: u32 = 0x0066CC;
const TAB_CHROME_MAX_VISIBLE: usize = 9;
const MACOS_TRAFFIC_LIGHT_SAFE_LEFT_PX: f32 = 170.0;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Rect {
x: f32,
y: f32,
w: f32,
h: f32,
color: [f32; 4],
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenderTheme {
pub background: u32,
pub foreground: u32,
pub selection_background: u32,
pub selection_foreground: u32,
pub cursor_background: u32,
pub cursor_foreground: u32,
pub cursor_thin: u32,
pub inverse_background: u32,
pub hyperlink_background: u32,
}
impl Default for RenderTheme {
fn default() -> Self {
Self {
background: DEFAULT_BACKGROUND,
foreground: DEFAULT_FOREGROUND,
selection_background: SELECTION_BG,
selection_foreground: SELECTION_FG,
cursor_background: CURSOR_BLOCK_BG,
cursor_foreground: CURSOR_BLOCK_FG,
cursor_thin: CURSOR_THIN_BG,
inverse_background: DEFAULT_INVERSE_BG,
hyperlink_background: HYPERLINK_BG,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BackgroundImageMode {
Cover,
Contain,
Stretch,
}
impl BackgroundImageMode {
pub fn from_config(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"contain" | "fit" => Self::Contain,
"stretch" | "fill" => Self::Stretch,
_ => Self::Cover,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BackgroundImageSettings {
pub path: String,
pub opacity: f32,
pub mode: BackgroundImageMode,
pub max_dimension: u32,
}
#[derive(Default)]
struct RowCacheEntry {
text: String,
signature: RowSignature,
}
#[derive(Default, PartialEq, Eq)]
struct RowSignature {
styles: Vec<(CellColor, CellColor, u16)>,
selection: Vec<usize>,
cursor: Option<(usize, usize)>,
}
impl RowSignature {
fn with_capacity(capacity: usize) -> Self {
Self {
styles: Vec::with_capacity(capacity),
selection: Vec::new(),
cursor: None,
}
}
fn push_cell(&mut self, cell: &Cell) {
self.styles.push((cell.fg, cell.bg, cell.attrs));
}
}
pub struct RenderFrame<'a> {
pub grid: &'a Grid,
pub scrollback: &'a Scrollback,
pub viewport_offset: usize,
pub selection: &'a Selection,
pub cursor: CursorVisual,
pub ime_preedit: Option<&'a str>,
pub hovered_hyperlink_id: u32,
pub tabs: &'a [RenderTab<'a>],
pub hovered_tab: Option<usize>,
pub status_text: Option<&'a str>,
}
pub struct RenderTab<'a> {
pub number: usize,
pub title: &'a str,
pub active: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabChromeHit {
Select(usize),
Close(usize),
}
impl TabChromeHit {
pub fn tab_index(self) -> usize {
match self {
Self::Select(index) | Self::Close(index) => index,
}
}
}
pub struct CursorVisual {
pub row: usize,
pub col: usize,
pub visible: bool,
pub style: CursorStyle,
}
struct CellTextBuffer {
buffer: glyphon::Buffer,
left: f32,
top: f32,
}
struct ChromeTextBuffer {
buffer: glyphon::Buffer,
left: f32,
top: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct TabChromeRect {
tab_index: usize,
x: f32,
y: f32,
w: f32,
h: f32,
}
struct BackgroundLayer {
_texture: wgpu::Texture,
_view: wgpu::TextureView,
_sampler: wgpu::Sampler,
bind_group: wgpu::BindGroup,
vertex_buffer: wgpu::Buffer,
image_width: u32,
image_height: u32,
opacity: f32,
mode: BackgroundImageMode,
}
#[derive(Debug, Clone)]
pub struct RenderSettings {
pub font_family: String,
pub font_size: f32,
pub line_height: f32,
pub top_padding_lines: f32,
pub cache_rows: bool,
pub theme: RenderTheme,
pub background_image: Option<BackgroundImageSettings>,
}
impl RenderSettings {
pub fn new(
font_family: String,
font_size: f32,
line_height: f32,
top_padding_lines: f32,
cache_rows: bool,
theme: RenderTheme,
background_image: Option<BackgroundImageSettings>,
) -> Self {
Self {
font_family,
font_size,
line_height,
top_padding_lines,
cache_rows,
theme,
background_image,
}
}
fn sanitized_font_size(&self) -> f32 {
finite_or(self.font_size, 14.0).clamp(6.0, 96.0)
}
fn sanitized_line_height(&self) -> f32 {
finite_or(self.line_height, 1.4).clamp(1.0, 3.0)
}
fn sanitized_top_padding_lines(&self) -> f32 {
sanitized_top_padding_lines(self.top_padding_lines)
}
}
impl Default for RenderSettings {
fn default() -> Self {
Self {
font_family: "OCR A Extended".into(),
font_size: 14.0,
line_height: 1.4,
top_padding_lines: 1.0,
cache_rows: true,
theme: RenderTheme::default(),
background_image: None,
}
}
}
impl BackgroundLayer {
fn load(
device: &wgpu::Device,
queue: &wgpu::Queue,
layout: &wgpu::BindGroupLayout,
settings: &BackgroundImageSettings,
target_width: u32,
target_height: u32,
) -> Option<Self> {
let path = expand_user_path(&settings.path);
let max_dimension = settings.max_dimension.clamp(256, 4096);
let reader = image::ImageReader::open(&path)
.map_err(|error| {
warn!(
"background image open failed at {}: {error}",
path.display()
);
error
})
.ok()?;
let decoded = reader
.decode()
.map_err(|error| {
warn!(
"background image decode failed at {}: {error}",
path.display()
);
error
})
.ok()?;
let image = if decoded.width().max(decoded.height()) > max_dimension {
decoded.resize(
max_dimension,
max_dimension,
image::imageops::FilterType::Triangle,
)
} else {
decoded
};
let rgba = image.to_rgba8();
let (image_width, image_height) = rgba.dimensions();
if image_width == 0 || image_height == 0 {
warn!("background image ignored because it has empty dimensions");
return None;
}
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("background_image_texture"),
size: wgpu::Extent3d {
width: image_width,
height: image_height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
rgba.as_raw(),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(image_width * 4),
rows_per_image: Some(image_height),
},
wgpu::Extent3d {
width: image_width,
height: image_height,
depth_or_array_layers: 1,
},
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("background_image_sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("background_image_bind_group"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&sampler),
},
],
});
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("background_image_vertices"),
size: BACKGROUND_VERTEX_BUFFER_BYTES,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layer = Self {
_texture: texture,
_view: view,
_sampler: sampler,
bind_group,
vertex_buffer,
image_width,
image_height,
opacity: finite_or(settings.opacity, 0.18).clamp(0.0, 1.0),
mode: settings.mode,
};
layer.update_vertices(queue, target_width, target_height);
info!(
"background image loaded: {} ({}x{}, opacity={:.2})",
path.display(),
image_width,
image_height,
layer.opacity
);
Some(layer)
}
fn update_vertices(&self, queue: &wgpu::Queue, target_width: u32, target_height: u32) {
let vertices = background_vertices(
self.image_width,
self.image_height,
target_width.max(1),
target_height.max(1),
self.opacity,
self.mode,
);
queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices));
}
}
pub struct Renderer {
pub surface: wgpu::Surface<'static>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub config: wgpu::SurfaceConfiguration,
_cache: glyphon::Cache,
atlas: glyphon::TextAtlas,
viewport: glyphon::Viewport,
font_system: glyphon::FontSystem,
swash_cache: glyphon::SwashCache,
text_renderer: glyphon::TextRenderer,
row_buffers: Vec<glyphon::Buffer>,
cell_text_buffers: Vec<CellTextBuffer>,
row_cache: Vec<RowCacheEntry>,
metrics: glyphon::Metrics,
buffer_w: f32,
width: u32,
height: u32,
_scale_factor: f64,
pub cell_width: f32,
pub cell_height: f32,
pub cols: usize,
pub rows: usize,
pub font_size_physical: f32,
settings: RenderSettings,
row_positions: Vec<(f32, f32)>,
viewport_dirty: bool,
redraw_count: u64,
rect_pipeline: wgpu::RenderPipeline,
rect_vertices: wgpu::Buffer,
rect_vertex_capacity_bytes: u64,
background_pipeline: wgpu::RenderPipeline,
background_bind_group_layout: wgpu::BindGroupLayout,
background_layer: Option<BackgroundLayer>,
}
impl Renderer {
pub async fn new(
window: Arc<Window>,
width: u32,
height: u32,
settings: RenderSettings,
) -> Result<Self, Box<dyn std::error::Error>> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: crate::platform::platform_backends(),
..wgpu::InstanceDescriptor::new_without_display_handle()
});
let surface = instance.create_surface(window.clone())?;
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::LowPower,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await?;
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: None,
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
experimental_features: wgpu::ExperimentalFeatures::default(),
memory_hints: wgpu::MemoryHints::Performance,
trace: wgpu::Trace::default(),
})
.await?;
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: width.max(1),
height: height.max(1),
present_mode: wgpu::PresentMode::AutoVsync,
alpha_mode: wgpu::CompositeAlphaMode::Auto,
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
let scale_factor = window.scale_factor();
let (cw, ch, fsz) = compute_cell_dimensions(scale_factor, &settings);
let mut font_system = glyphon::FontSystem::new();
let cache = glyphon::Cache::new(&device);
let mut atlas = glyphon::TextAtlas::new(&device, &queue, &cache, surface_format);
let mut viewport = glyphon::Viewport::new(&device, &cache);
viewport.update(
&queue,
glyphon::Resolution {
width: width.max(1),
height: height.max(1),
},
);
let swash_cache = glyphon::SwashCache::new();
let metrics = glyphon::Metrics::new(fsz, ch);
let (c, r) = grid_dimensions_from_cell_size(
width,
height,
cw,
ch,
settings.sanitized_top_padding_lines(),
);
let bw = c as f32 * cw;
let (rb, rtc) =
Self::build_row_buffers(&mut font_system, &metrics, c, r, bw, &settings.font_family);
let padding_x = cw * 2.0;
let padding_y = ch * settings.sanitized_top_padding_lines();
let rp = Self::compute_row_positions(r, ch, padding_x, padding_y);
let tr = glyphon::TextRenderer::new(
&mut atlas,
&device,
wgpu::MultisampleState::default(),
None,
);
let (rect_pipeline, rect_vertices, rect_vertex_capacity_bytes) =
Self::build_rect_pipeline(&device, surface_format);
let (background_pipeline, background_bind_group_layout) =
Self::build_background_pipeline(&device, surface_format);
let background_layer = settings.background_image.as_ref().and_then(|background| {
BackgroundLayer::load(
&device,
&queue,
&background_bind_group_layout,
background,
width,
height,
)
});
info!(
"font={}px cell={}x{} grid={}x{} scale={}",
fsz, cw, ch, c, r, scale_factor
);
Ok(Self {
surface,
device,
queue,
config,
_cache: cache,
atlas,
viewport,
font_system,
swash_cache,
text_renderer: tr,
row_buffers: rb,
cell_text_buffers: Vec::new(),
row_cache: rtc,
metrics,
buffer_w: bw,
width,
height,
_scale_factor: scale_factor,
cell_width: cw,
cell_height: ch,
cols: c,
rows: r,
font_size_physical: fsz,
settings,
row_positions: rp,
viewport_dirty: true,
redraw_count: 0,
rect_pipeline,
rect_vertices,
rect_vertex_capacity_bytes,
background_pipeline,
background_bind_group_layout,
background_layer,
})
}
fn build_background_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) {
let vs = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("background_vs"),
source: wgpu::ShaderSource::Wgsl(BACKGROUND_VS.into()),
});
let fs = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("background_fs"),
source: wgpu::ShaderSource::Wgsl(BACKGROUND_FS.into()),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("background_bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("background_pl"),
bind_group_layouts: &[Some(&bind_group_layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("background_pipe"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[VertexBufferLayout {
array_stride: (BG_FLOATS_PER_VERTEX * std::mem::size_of::<f32>()) as u64,
step_mode: VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: (2 * std::mem::size_of::<f32>()) as u64,
shader_location: 1,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32,
offset: (4 * std::mem::size_of::<f32>()) as u64,
shader_location: 2,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
(pipeline, bind_group_layout)
}
fn build_rect_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
) -> (wgpu::RenderPipeline, wgpu::Buffer, u64) {
let vs = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("cursor_vs"),
source: wgpu::ShaderSource::Wgsl(CURSOR_VS.into()),
});
let fs = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("cursor_fs"),
source: wgpu::ShaderSource::Wgsl(CURSOR_FS.into()),
});
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("rect_pl"),
bind_group_layouts: &[],
..Default::default()
});
let rect_vertices = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(RECT_VERTEX_BUFFER_LABEL),
size: INITIAL_RECT_VERTEX_BYTES,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let pipe = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("rect_pipe"),
layout: Some(&pl),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("vs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: &[VertexBufferLayout {
array_stride: (RECT_FLOATS_PER_VERTEX * std::mem::size_of::<f32>()) as u64,
step_mode: VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x2,
offset: 0,
shader_location: 0,
},
wgpu::VertexAttribute {
format: wgpu::VertexFormat::Float32x4,
offset: (2 * std::mem::size_of::<f32>()) as u64,
shader_location: 1,
},
],
}],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("fs_main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
(pipe, rect_vertices, INITIAL_RECT_VERTEX_BYTES)
}
pub fn grid_dimensions(
width: u32,
height: u32,
scale_factor: f64,
settings: &RenderSettings,
) -> (usize, usize) {
let (cw, ch, _) = compute_cell_dimensions(scale_factor, settings);
grid_dimensions_from_cell_size(
width,
height,
cw,
ch,
settings.sanitized_top_padding_lines(),
)
}
fn build_row_buffers(
fs: &mut glyphon::FontSystem,
m: &glyphon::Metrics,
cols: usize,
rows: usize,
bw: f32,
font_family: &str,
) -> (Vec<glyphon::Buffer>, Vec<RowCacheEntry>) {
let blanks = " ".repeat(cols);
let attrs = attrs_for_family(font_family);
let mut bufs = Vec::with_capacity(rows);
let mut cache = Vec::with_capacity(rows);
for _ in 0..rows {
let mut buf = glyphon::Buffer::new(fs, *m);
buf.set_size(fs, Some(bw), Some(m.line_height));
buf.set_text(fs, &blanks, &attrs, glyphon::Shaping::Advanced, None);
buf.shape_until_scroll(fs, false);
bufs.push(buf);
cache.push(RowCacheEntry::default());
}
(bufs, cache)
}
fn compute_row_positions(r: usize, ch: f32, px: f32, py: f32) -> Vec<(f32, f32)> {
(0..r).map(|ri| (px, py + ri as f32 * ch)).collect()
}
pub fn padding_x(&self) -> f32 {
self.cell_width * 2.0
}
pub fn padding_y(&self) -> f32 {
self.cell_height * self.settings.sanitized_top_padding_lines()
}
pub fn mark_viewport_changed(&mut self) {
self.viewport_dirty = true;
}
pub fn tab_hit_at_position(
&self,
x: f32,
y: f32,
tabs: &[RenderTab<'_>],
) -> Option<TabChromeHit> {
tab_chrome_hit_at_position(self.cell_width, self.cell_height, self.width, tabs, x, y)
}
pub fn resize(&mut self, width: u32, height: u32) -> (usize, usize) {
self.width = width;
self.height = height;
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(&self.device, &self.config);
self.viewport.update(
&self.queue,
glyphon::Resolution {
width: width.max(1),
height: height.max(1),
},
);
let px = self.padding_x();
let py = self.padding_y();
let (nc, nr) = grid_dimensions_from_cell_size(
width,
height,
self.cell_width,
self.cell_height,
self.settings.sanitized_top_padding_lines(),
);
self.buffer_w = nc as f32 * self.cell_width;
(self.row_buffers, self.row_cache) = Self::build_row_buffers(
&mut self.font_system,
&self.metrics,
nc,
nr,
self.buffer_w,
&self.settings.font_family,
);
self.cell_text_buffers.clear();
self.row_positions = Self::compute_row_positions(nr, self.cell_height, px, py);
if let Some(background) = &self.background_layer {
background.update_vertices(&self.queue, width, height);
}
(nc, nr)
}
pub fn reconfigure(&mut self, settings: RenderSettings) -> (usize, usize) {
self.settings = settings;
let (cw, ch, fsz) = compute_cell_dimensions(self._scale_factor, &self.settings);
self.cell_width = cw;
self.cell_height = ch;
self.font_size_physical = fsz;
self.metrics = glyphon::Metrics::new(fsz, ch);
let (nc, nr) = grid_dimensions_from_cell_size(
self.width,
self.height,
self.cell_width,
self.cell_height,
self.settings.sanitized_top_padding_lines(),
);
self.cols = nc;
self.rows = nr;
self.buffer_w = nc as f32 * self.cell_width;
(self.row_buffers, self.row_cache) = Self::build_row_buffers(
&mut self.font_system,
&self.metrics,
nc,
nr,
self.buffer_w,
&self.settings.font_family,
);
self.cell_text_buffers.clear();
self.row_positions =
Self::compute_row_positions(nr, self.cell_height, self.padding_x(), self.padding_y());
self.background_layer = self
.settings
.background_image
.as_ref()
.and_then(|background| {
BackgroundLayer::load(
&self.device,
&self.queue,
&self.background_bind_group_layout,
background,
self.width,
self.height,
)
});
self.viewport_dirty = true;
(nc, nr)
}
pub fn cols(&self) -> usize {
self.cols
}
pub fn rows(&self) -> usize {
self.rows
}
pub fn set_cols_rows(&mut self, cols: usize, rows: usize) {
self.cols = cols;
self.rows = rows;
}
fn viewport_row_cells<'a>(
&self,
grid: &'a Grid,
scrollback: &'a Scrollback,
offset: usize,
ri: usize,
) -> &'a [Cell] {
let sb_vis = offset.min(scrollback.len());
if ri < sb_vis {
let idx = scrollback.len() - sb_vis + ri;
scrollback.row_cells(idx).unwrap_or(&[])
} else {
let gr = ri - sb_vis;
if gr < grid.rows() {
grid.row_cells(gr)
} else {
&[]
}
}
}
fn fill_buffer(
&mut self,
ri: usize,
cells: &[Cell],
selection: &Selection,
cursor: &CursorVisual,
preedit: Option<(&str, usize)>,
) -> bool {
let mut text = String::with_capacity(cells.len());
let mut byte_off: Vec<usize> = Vec::with_capacity(cells.len() + 1);
let mut signature = RowSignature::with_capacity(cells.len());
let block_cursor = block_cursor_text_span(cursor, ri, cells);
let font_family = self.settings.font_family.clone();
let normal = attrs_for_family(&font_family)
.color(glyphon_color_from_rgb(self.settings.theme.foreground));
signature.cursor = block_cursor;
byte_off.push(0);
for (col, c) in cells.iter().enumerate() {
if preedit.is_some_and(|(_, preedit_col)| preedit_col == col)
&& let Some((preedit_text, _)) = preedit
{
text.push_str(preedit_text);
}
if c.continuation {
text.push(' ');
} else if is_cell_positioned_text(c) {
self.push_cell_text_buffer(
ri,
col,
c,
render_cluster_style_key(
cells,
ri,
col,
selection,
block_cursor,
&self.settings.theme,
),
&normal,
);
text.push(' ');
} else {
c.push_text(&mut text);
}
byte_off.push(text.len());
signature.push_cell(c);
if selection.contains(ri, col) {
signature.selection.push(col);
}
}
if let Some((preedit_text, preedit_col)) = preedit
&& preedit_col >= cells.len()
{
text.push_str(preedit_text);
}
if self.settings.cache_rows
&& !self.viewport_dirty
&& self
.row_cache
.get(ri)
.is_some_and(|entry| entry.text == text && entry.signature == signature)
{
return false;
}
self.row_cache[ri] = RowCacheEntry {
text: text.clone(),
signature,
};
let buffer = &mut self.row_buffers[ri];
if cells.is_empty() {
buffer.set_size(
&mut self.font_system,
Some(self.buffer_w),
Some(self.metrics.line_height),
);
buffer.set_text(
&mut self.font_system,
"",
&normal,
glyphon::Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
return true;
}
let selection_span = selection_cell_span(selection, ri, cells);
let has_style = cells.iter().any(|c| c.fg != 0 || c.bg != 0 || c.attrs != 0)
|| selection_span.is_some()
|| block_cursor.is_some();
if !has_style {
buffer.set_size(
&mut self.font_system,
Some(self.buffer_w),
Some(self.metrics.line_height),
);
buffer.set_text(
&mut self.font_system,
&text,
&normal,
glyphon::Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
return true;
}
let mut spans: Vec<(&str, glyphon::Attrs)> = Vec::new();
let mut ssc = 0usize;
let mut cur =
render_cluster_style_key(cells, ri, 0, selection, block_cursor, &self.settings.theme);
for i in 1..cells.len() {
if cells[i].continuation {
continue;
}
let nc = render_cluster_style_key(
cells,
ri,
i,
selection,
block_cursor,
&self.settings.theme,
);
if nc != cur {
let bs = byte_off[ssc];
let be = byte_off[i];
if be > bs {
spans.push((
&text[bs..be],
attrs_for_style(&normal, cur, &self.settings.theme),
));
}
ssc = i;
cur = nc;
}
}
{
let bs = byte_off[ssc];
if bs < text.len() {
spans.push((
&text[bs..],
attrs_for_style(&normal, cur, &self.settings.theme),
));
}
}
buffer.set_size(
&mut self.font_system,
Some(self.buffer_w),
Some(self.metrics.line_height),
);
if spans.is_empty() {
buffer.set_text(
&mut self.font_system,
&text,
&normal,
glyphon::Shaping::Advanced,
None,
);
} else if spans.len() == 1 {
buffer.set_text(
&mut self.font_system,
&text,
&attrs_for_style(&normal, cur, &self.settings.theme),
glyphon::Shaping::Advanced,
None,
);
} else {
buffer.set_rich_text(
&mut self.font_system,
spans,
&normal,
glyphon::Shaping::Advanced,
None,
);
}
buffer.shape_until_scroll(&mut self.font_system, false);
true
}
fn push_cell_text_buffer(
&mut self,
ri: usize,
col: usize,
cell: &Cell,
style: StyleKey,
normal: &glyphon::Attrs<'_>,
) {
let Some((left_base, top)) = self.row_positions.get(ri).copied() else {
return;
};
let mut text = String::new();
cell.push_text(&mut text);
if text.is_empty() {
return;
}
let width = cell.display_width().max(1) as f32 * self.cell_width;
let mut buffer = glyphon::Buffer::new(&mut self.font_system, self.metrics);
buffer.set_size(
&mut self.font_system,
Some(width),
Some(self.metrics.line_height),
);
buffer.set_text(
&mut self.font_system,
&text,
&attrs_for_style(normal, style, &self.settings.theme),
glyphon::Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
self.cell_text_buffers.push(CellTextBuffer {
buffer,
left: left_base + col as f32 * self.cell_width,
top,
});
}
fn build_tab_chrome(
&mut self,
tabs: &[RenderTab<'_>],
hovered_tab: Option<usize>,
) -> (Vec<ChromeTextBuffer>, Vec<f32>) {
let mut text_buffers = Vec::new();
let mut rect_vertices = Vec::new();
if tabs.is_empty() {
return (text_buffers, rect_vertices);
}
let top = tab_chrome_top(self.cell_height);
let height = tab_chrome_height(self.cell_height);
let padding_x = tab_chrome_padding_x(self.cell_width);
let close_slot_width = tab_chrome_close_slot_width(self.cell_width);
let gap = tab_chrome_gap(self.cell_width);
let tab_rects = tab_chrome_rects(self.cell_width, self.cell_height, self.width, tabs);
let font_family = self.settings.font_family.clone();
let active_text = glyphon_color_from_rgb(self.settings.theme.foreground);
let inactive_text = glyphon_color_from_rgb(mix_rgb(
self.settings.theme.foreground,
self.settings.theme.background,
0.58,
));
let active_bg = mix_rgb(
self.settings.theme.selection_background,
self.settings.theme.inverse_background,
0.34,
);
let inactive_bg = mix_rgb(
self.settings.theme.background,
self.settings.theme.inverse_background,
0.16,
);
for rect in &tab_rects {
let tab = &tabs[rect.tab_index];
let label = tab_chrome_label(tab);
let (bg, bg_alpha, text_color) = if tab.active {
(active_bg, 0.72, active_text)
} else {
(inactive_bg, 0.24, inactive_text)
};
push_rect_vertices(
&mut rect_vertices,
Rect {
x: rect.x,
y: rect.y,
w: rect.w,
h: rect.h,
color: rgb_u32_to_f32(bg, bg_alpha),
},
self.width,
self.height,
);
if tab.active {
push_rect_vertices(
&mut rect_vertices,
Rect {
x: rect.x,
y: rect.y + rect.h - 2.0,
w: rect.w,
h: 2.0,
color: rgb_u32_to_f32(self.settings.theme.cursor_thin, 0.88),
},
self.width,
self.height,
);
}
if hovered_tab == Some(rect.tab_index) {
let close_rect = tab_chrome_close_rect(rect, self.cell_width);
let mut buffer = glyphon::Buffer::new(&mut self.font_system, self.metrics);
buffer.set_size(
&mut self.font_system,
Some(close_rect.w.max(self.cell_width)),
Some(self.metrics.line_height),
);
buffer.set_text(
&mut self.font_system,
"×",
&attrs_for_family(&font_family)
.color(text_color)
.weight(glyphon::Weight::BOLD),
glyphon::Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
text_buffers.push(ChromeTextBuffer {
buffer,
left: close_rect.x + (close_rect.w - self.cell_width) * 0.42,
top: rect.y + (rect.h - self.metrics.line_height) * 0.5,
});
}
let mut attrs = attrs_for_family(&font_family).color(text_color);
if tab.active {
attrs = attrs.weight(glyphon::Weight::BOLD);
}
let mut buffer = glyphon::Buffer::new(&mut self.font_system, self.metrics);
buffer.set_size(
&mut self.font_system,
Some((rect.w - padding_x * 2.0 - close_slot_width).max(self.cell_width)),
Some(self.metrics.line_height),
);
buffer.set_text(
&mut self.font_system,
&label,
&attrs,
glyphon::Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
text_buffers.push(ChromeTextBuffer {
buffer,
left: rect.x + padding_x + close_slot_width,
top: rect.y + (rect.h - self.metrics.line_height) * 0.5,
});
}
let visible_limit = tabs.len().min(TAB_CHROME_MAX_VISIBLE);
if tab_rects.len() < visible_limit {
let hidden = tabs.len() - tab_rects.len();
let x = tab_rects
.last()
.map(|rect| rect.x + rect.w + gap)
.unwrap_or_else(|| tab_chrome_start_x(self.cell_width));
self.push_tab_overflow_chrome(
hidden,
x,
top,
height,
padding_x,
&font_family,
inactive_text,
&mut text_buffers,
&mut rect_vertices,
);
}
(text_buffers, rect_vertices)
}
fn push_status_chrome(
&mut self,
status_text: &str,
text_buffers: &mut Vec<ChromeTextBuffer>,
rect_vertices: &mut Vec<f32>,
) {
let label = truncate_for_chrome(status_text, 54);
if label.trim().is_empty() {
return;
}
let top = tab_chrome_top(self.cell_height);
let height = tab_chrome_height(self.cell_height);
let padding_x = tab_chrome_padding_x(self.cell_width);
let max_width = (self.width as f32 * 0.34)
.max(self.cell_width * 12.0)
.min(self.width as f32 - self.cell_width * 4.0);
let text_width = label.chars().count() as f32 * self.cell_width;
let width = (text_width + padding_x * 2.0)
.clamp(self.cell_width * 8.0, max_width)
.min(max_width);
let x = self.width as f32 - self.cell_width * 2.0 - width;
if x <= self.cell_width {
return;
}
push_rect_vertices(
rect_vertices,
Rect {
x,
y: top,
w: width,
h: height,
color: rgb_u32_to_f32(self.settings.theme.selection_background, 0.64),
},
self.width,
self.height,
);
let text_color = glyphon_color_from_rgb(self.settings.theme.foreground);
let mut buffer = glyphon::Buffer::new(&mut self.font_system, self.metrics);
buffer.set_size(
&mut self.font_system,
Some((width - padding_x * 2.0).max(self.cell_width)),
Some(self.metrics.line_height),
);
buffer.set_text(
&mut self.font_system,
&label,
&attrs_for_family(&self.settings.font_family).color(text_color),
glyphon::Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
text_buffers.push(ChromeTextBuffer {
buffer,
left: x + padding_x,
top: top + (height - self.metrics.line_height) * 0.5,
});
}
#[allow(clippy::too_many_arguments)]
fn push_tab_overflow_chrome(
&mut self,
hidden: usize,
x: f32,
top: f32,
height: f32,
padding_x: f32,
font_family: &str,
text_color: glyphon::Color,
text_buffers: &mut Vec<ChromeTextBuffer>,
rect_vertices: &mut Vec<f32>,
) {
let label = format!("+{hidden}");
let width = (label.chars().count() as f32 * self.cell_width + padding_x * 2.0)
.max(self.cell_width * 4.0);
if x + width > self.width as f32 - self.cell_width {
return;
}
push_rect_vertices(
rect_vertices,
Rect {
x,
y: top,
w: width,
h: height,
color: rgb_u32_to_f32(self.settings.theme.inverse_background, 0.18),
},
self.width,
self.height,
);
let mut buffer = glyphon::Buffer::new(&mut self.font_system, self.metrics);
buffer.set_size(
&mut self.font_system,
Some((width - padding_x * 2.0).max(self.cell_width)),
Some(self.metrics.line_height),
);
buffer.set_text(
&mut self.font_system,
&label,
&attrs_for_family(font_family).color(text_color),
glyphon::Shaping::Advanced,
None,
);
buffer.shape_until_scroll(&mut self.font_system, false);
text_buffers.push(ChromeTextBuffer {
buffer,
left: x + padding_x,
top: top + (height - self.metrics.line_height) * 0.5,
});
}
pub fn draw(&mut self, frame: RenderFrame<'_>) -> Option<u64> {
let st = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(s) => s,
CurrentSurfaceTexture::Suboptimal(s) => {
self.surface.configure(&self.device, &self.config);
s
}
_ => return None,
};
let view = st
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut enc = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("panasyn enc"),
});
let max_rows = self.rows.min(self.row_buffers.len());
self.cell_text_buffers.clear();
for ri in 0..max_rows {
let cells =
self.viewport_row_cells(frame.grid, frame.scrollback, frame.viewport_offset, ri);
let preedit = frame
.ime_preedit
.filter(|preedit| !preedit.is_empty() && frame.cursor.row == ri)
.map(|preedit| (preedit, frame.cursor.col));
self.fill_buffer(ri, cells, frame.selection, &frame.cursor, preedit);
}
self.viewport_dirty = false;
let (mut chrome_text_buffers, mut rect_vertices) =
self.build_tab_chrome(frame.tabs, frame.hovered_tab);
if let Some(status_text) = frame.status_text {
self.push_status_chrome(status_text, &mut chrome_text_buffers, &mut rect_vertices);
}
let mut tas: Vec<glyphon::TextArea> = self.row_buffers[..max_rows]
.iter()
.enumerate()
.map(|(ri, buf)| {
let (l, t) = self.row_positions[ri];
glyphon::TextArea {
buffer: buf,
left: l,
top: t,
scale: 1.0,
bounds: glyphon::TextBounds {
left: 0,
top: 0,
right: self.width as i32,
bottom: self.height as i32,
},
default_color: glyphon_color_from_rgb(self.settings.theme.foreground),
custom_glyphs: &[],
}
})
.collect();
tas.extend(
self.cell_text_buffers
.iter()
.map(|cell_text| glyphon::TextArea {
buffer: &cell_text.buffer,
left: cell_text.left,
top: cell_text.top,
scale: 1.0,
bounds: glyphon::TextBounds {
left: 0,
top: 0,
right: self.width as i32,
bottom: self.height as i32,
},
default_color: glyphon_color_from_rgb(self.settings.theme.foreground),
custom_glyphs: &[],
}),
);
for chrome_text in &chrome_text_buffers {
tas.push(glyphon::TextArea {
buffer: &chrome_text.buffer,
left: chrome_text.left,
top: chrome_text.top,
scale: 1.0,
bounds: glyphon::TextBounds {
left: 0,
top: 0,
right: self.width as i32,
bottom: self.height as i32,
},
default_color: glyphon_color_from_rgb(self.settings.theme.foreground),
custom_glyphs: &[],
});
}
if let Err(e) = self.text_renderer.prepare(
&self.device,
&self.queue,
&mut self.font_system,
&mut self.atlas,
&self.viewport,
tas,
&mut self.swash_cache,
) {
error!("prepare: {e}");
}
self.collect_background_rects(&frame, max_rows, &mut rect_vertices);
if frame.cursor.visible && frame.cursor.style == CursorStyle::Block {
let cells = self.viewport_row_cells(
frame.grid,
frame.scrollback,
frame.viewport_offset,
frame.cursor.row,
);
self.push_cursor_rect(
frame.cursor.row,
frame.cursor.col,
frame.cursor.style,
cells,
&mut rect_vertices,
);
}
let background_vertex_count = rect_vertices.len() / RECT_FLOATS_PER_VERTEX;
if frame.cursor.visible && frame.cursor.style != CursorStyle::Block {
let cells = self.viewport_row_cells(
frame.grid,
frame.scrollback,
frame.viewport_offset,
frame.cursor.row,
);
self.push_cursor_rect(
frame.cursor.row,
frame.cursor.col,
frame.cursor.style,
cells,
&mut rect_vertices,
);
}
let total_rect_vertex_count = rect_vertices.len() / RECT_FLOATS_PER_VERTEX;
self.upload_rect_vertices(&rect_vertices);
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("panasyn pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu_color_from_rgb(
self.settings.theme.background,
)),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
self.draw_background(&mut pass);
self.draw_rect_vertices(&mut pass, 0, background_vertex_count);
if let Err(e) = self
.text_renderer
.render(&self.atlas, &self.viewport, &mut pass)
{
error!("render: {e}");
}
self.draw_rect_vertices(
&mut pass,
background_vertex_count,
total_rect_vertex_count.saturating_sub(background_vertex_count),
);
}
self.queue.submit(Some(enc.finish()));
st.present();
self.redraw_count += 1;
Some(self.redraw_count)
}
fn draw_background(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(background) = &self.background_layer else {
return;
};
pass.set_pipeline(&self.background_pipeline);
pass.set_bind_group(0, &background.bind_group, &[]);
pass.set_vertex_buffer(0, background.vertex_buffer.slice(..));
pass.draw(0..BG_VERTICES_PER_QUAD as u32, 0..1);
}
fn collect_background_rects(
&self,
frame: &RenderFrame<'_>,
max_rows: usize,
out: &mut Vec<f32>,
) {
for ri in 0..max_rows {
let cells =
self.viewport_row_cells(frame.grid, frame.scrollback, frame.viewport_offset, ri);
let (_, top) = self.row_positions[ri];
let mut run: Option<(usize, usize, [f32; 4])> = None;
let background_end = row_background_end(cells);
for (col, cell) in cells[..background_end].iter().enumerate() {
let display_width = cell.display_width();
if display_width == 0 {
continue;
}
let color = (!frame.selection.contains(ri, col))
.then(|| {
background_color(cell, frame.hovered_hyperlink_id, &self.settings.theme)
})
.flatten();
match (run, color) {
(Some((start, width, run_color)), Some(color))
if run_color == color && start + width == col =>
{
run = Some((start, width + display_width, color));
}
(Some((start, width, run_color)), Some(color)) => {
self.push_cell_rect(out, start, top, width, run_color);
run = Some((col, display_width, color));
}
(Some((start, width, run_color)), None) => {
self.push_cell_rect(out, start, top, width, run_color);
run = None;
}
(None, Some(color)) => run = Some((col, display_width, color)),
(None, None) => {}
}
}
if let Some((start, width, color)) = run {
self.push_cell_rect(out, start, top, width, color);
}
if let Some((start, width)) = selection_rect_span(frame.selection, ri, cells) {
self.push_cell_rect(
out,
start,
top,
width,
rgb_u32_to_f32(self.settings.theme.selection_background, SELECTION_BG_ALPHA),
);
}
}
}
fn push_cell_rect(
&self,
out: &mut Vec<f32>,
start_col: usize,
top: f32,
width_cols: usize,
color: [f32; 4],
) {
push_rect_vertices(
out,
Rect {
x: self.cell_width * 2.0 + start_col as f32 * self.cell_width,
y: top,
w: self.cell_width * width_cols as f32,
h: self.cell_height,
color,
},
self.width,
self.height,
);
}
fn push_cursor_rect(
&self,
row: usize,
col: usize,
style: CursorStyle,
cells: &[Cell],
out: &mut Vec<f32>,
) {
if row >= self.rows {
return;
}
let px = self.padding_x();
let py = self.padding_y();
let (start_col, width_cols) = cursor_visual_cell_span(cells, col);
let x = px + start_col as f32 * self.cell_width;
let (y, w, h) = match style {
CursorStyle::Block => (
py + row as f32 * self.cell_height,
self.cell_width * width_cols as f32,
self.cell_height,
),
CursorStyle::Underline => (
py + (row + 1) as f32 * self.cell_height - 2.0,
self.cell_width * width_cols as f32,
2.0,
),
CursorStyle::Bar => (py + row as f32 * self.cell_height, 2.0, self.cell_height),
};
let color = match style {
CursorStyle::Block => rgb_u32_to_f32(self.settings.theme.cursor_background, 1.0),
CursorStyle::Underline | CursorStyle::Bar => {
rgb_u32_to_f32(self.settings.theme.cursor_thin, 0.72)
}
};
push_rect_vertices(out, Rect { x, y, w, h, color }, self.width, self.height);
}
fn upload_rect_vertices(&mut self, vertices: &[f32]) {
if vertices.is_empty() {
return;
}
let byte_len = std::mem::size_of_val(vertices) as u64;
if byte_len > self.rect_vertex_capacity_bytes {
let new_capacity = byte_len.next_power_of_two();
self.rect_vertices = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(RECT_VERTEX_BUFFER_LABEL),
size: new_capacity,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.rect_vertex_capacity_bytes = new_capacity;
}
self.queue
.write_buffer(&self.rect_vertices, 0, bytemuck::cast_slice(vertices));
}
fn draw_rect_vertices(&self, pass: &mut wgpu::RenderPass<'_>, start: usize, count: usize) {
if count == 0 {
return;
}
let start_vertex = start as u32;
let end_vertex = start_vertex + count as u32;
pass.set_pipeline(&self.rect_pipeline);
pass.set_vertex_buffer(0, self.rect_vertices.slice(..));
pass.draw(start_vertex..end_vertex, 0..1);
}
}
fn to_glyphon_color(c: CellColor, theme: &RenderTheme) -> glyphon::Color {
let rgb = if c == 0 {
theme.foreground
} else {
resolve_color(c)
};
glyphon_color_from_rgb(rgb)
}
fn glyphon_color_from_rgb(rgb: u32) -> glyphon::Color {
glyphon::Color::rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)
}
fn background_color(
cell: &Cell,
hovered_hyperlink_id: u32,
theme: &RenderTheme,
) -> Option<[f32; 4]> {
if hovered_hyperlink_id != 0 && cell.hyperlink_id == hovered_hyperlink_id {
return Some(rgb_u32_to_f32(theme.hyperlink_background, 0.32));
}
effective_cell_colors(cell, theme)
.1
.map(|bg| rgb_u32_to_f32(bg, 1.0))
}
fn inverse_background_color(cell: &Cell, theme: &RenderTheme) -> u32 {
if cell.fg == 0 && cell.bg == 0 {
theme.inverse_background
} else {
resolve_cell_fg(cell, theme)
}
}
fn effective_cell_colors(cell: &Cell, theme: &RenderTheme) -> (u32, Option<u32>) {
let normal_fg = resolve_cell_fg(cell, theme);
let normal_bg = resolve_cell_bg(cell);
let (mut fg, bg) = if cell.attrs & ATTR_INVERSE != 0 {
let inverse_fg = normal_bg.unwrap_or(theme.background);
let inverse_bg = inverse_background_color(cell, theme);
(inverse_fg, Some(inverse_bg))
} else {
(normal_fg, normal_bg)
};
if cell.attrs & ATTR_HIDDEN != 0 {
fg = bg.unwrap_or(theme.background);
}
(fg, bg)
}
fn resolve_cell_fg(cell: &Cell, theme: &RenderTheme) -> u32 {
if cell.fg == 0 {
theme.foreground
} else {
resolve_color(cell.fg)
}
}
fn resolve_cell_bg(cell: &Cell) -> Option<u32> {
let bg = resolve_bg(cell.bg);
(bg != 0).then_some(bg)
}
fn wgpu_color_from_rgb(rgb: u32) -> wgpu::Color {
wgpu::Color {
r: srgb_byte_to_linear(((rgb >> 16) & 0xff) as u8) as f64,
g: srgb_byte_to_linear(((rgb >> 8) & 0xff) as u8) as f64,
b: srgb_byte_to_linear((rgb & 0xff) as u8) as f64,
a: 1.0,
}
}
fn row_background_end(cells: &[Cell]) -> usize {
let mut end = cells.len();
while end > 0 && is_default_background_padding(&cells[end - 1]) {
end -= 1;
}
if end < cells.len() {
let mut inverse_blanks = 0;
while end > 0 && inverse_blanks < 2 && is_inverse_background_padding(&cells[end - 1]) {
end -= 1;
inverse_blanks += 1;
}
}
end
}
fn selection_rect_span(
selection: &Selection,
row: usize,
cells: &[Cell],
) -> Option<(usize, usize)> {
let (start, end) = selection_cell_span(selection, row, cells)?;
let width = cells[start..end]
.iter()
.map(Cell::display_width)
.sum::<usize>();
(width > 0).then_some((start, width))
}
fn selection_cell_span(
selection: &Selection,
row: usize,
cells: &[Cell],
) -> Option<(usize, usize)> {
let (mut start, mut end) = selection.span_in_row(row, cells.len())?;
start = start.min(cells.len());
end = end.min(cells.len());
while start > 0 && start < cells.len() && cells[start].continuation {
start -= 1;
}
while end > start && is_trailing_selection_blank(&cells[end - 1]) {
end -= 1;
}
(end > start).then_some((start, end))
}
fn is_trailing_selection_blank(cell: &Cell) -> bool {
!cell.continuation
&& cell.c == ' '
&& cell.extra.is_none()
&& cell.hyperlink_id == 0
&& resolve_bg(cell.bg) == 0
}
fn is_default_background_padding(cell: &Cell) -> bool {
!cell.continuation
&& cell.c == ' '
&& cell.extra.is_none()
&& cell.fg == 0
&& cell.bg == 0
&& cell.hyperlink_id == 0
&& cell.attrs == 0
}
fn is_inverse_background_padding(cell: &Cell) -> bool {
!cell.continuation
&& cell.c == ' '
&& cell.extra.is_none()
&& cell.fg == 0
&& cell.bg == 0
&& cell.hyperlink_id == 0
&& cell.attrs == ATTR_INVERSE
}
fn rgb_u32_to_f32(rgb: u32, alpha: f32) -> [f32; 4] {
[
srgb_byte_to_linear(((rgb >> 16) & 0xff) as u8),
srgb_byte_to_linear(((rgb >> 8) & 0xff) as u8),
srgb_byte_to_linear((rgb & 0xff) as u8),
alpha,
]
}
fn mix_rgb(a: u32, b: u32, b_weight: f32) -> u32 {
let b_weight = finite_or(b_weight, 0.5).clamp(0.0, 1.0);
let a_weight = 1.0 - b_weight;
let channel = |shift: u32| {
let av = ((a >> shift) & 0xff_u32) as f32;
let bv = ((b >> shift) & 0xff_u32) as f32;
(av * a_weight + bv * b_weight).round().clamp(0.0, 255.0) as u32
};
(channel(16) << 16) | (channel(8) << 8) | channel(0)
}
fn rgb_to_cell_color(rgb: u32) -> CellColor {
color_rgb((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)
}
fn tab_chrome_start_x(cell_width: f32) -> f32 {
(finite_or(cell_width, 12.0) * 9.5).max(MACOS_TRAFFIC_LIGHT_SAFE_LEFT_PX)
}
fn tab_chrome_top(cell_height: f32) -> f32 {
(finite_or(cell_height, 20.0) * 0.52).max(7.0)
}
fn tab_chrome_height(cell_height: f32) -> f32 {
(finite_or(cell_height, 20.0) * 1.42).max(22.0)
}
fn tab_chrome_padding_x(cell_width: f32) -> f32 {
(finite_or(cell_width, 12.0) * 1.08).max(9.0)
}
fn tab_chrome_gap(cell_width: f32) -> f32 {
(finite_or(cell_width, 12.0) * 0.55).max(6.0)
}
fn tab_chrome_close_slot_width(cell_width: f32) -> f32 {
(finite_or(cell_width, 12.0) * 1.45).max(18.0)
}
fn tab_chrome_close_rect(rect: &TabChromeRect, cell_width: f32) -> Rect {
let padding_x = tab_chrome_padding_x(cell_width);
let slot_width = tab_chrome_close_slot_width(cell_width);
Rect {
x: rect.x + padding_x * 0.42,
y: rect.y,
w: slot_width,
h: rect.h,
color: [0.0; 4],
}
}
fn tab_chrome_min_width(cell_width: f32) -> f32 {
(finite_or(cell_width, 12.0) * 7.0).max(78.0)
}
fn tab_chrome_max_width(cell_width: f32) -> f32 {
(finite_or(cell_width, 12.0) * 19.0).max(190.0)
}
fn tab_chrome_label(tab: &RenderTab<'_>) -> String {
let title = if tab.title.trim().is_empty() {
"shell"
} else {
tab.title.trim()
};
format!("{} {}", tab.number, title)
}
fn truncate_for_chrome(value: &str, max_chars: usize) -> String {
let mut chars = value.chars();
let mut out: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
out.push_str("...");
}
out
}
fn tab_chrome_rects(
cell_width: f32,
cell_height: f32,
screen_width: u32,
tabs: &[RenderTab<'_>],
) -> Vec<TabChromeRect> {
let top = tab_chrome_top(cell_height);
let height = tab_chrome_height(cell_height);
let padding_x = tab_chrome_padding_x(cell_width);
let close_slot_width = tab_chrome_close_slot_width(cell_width);
let gap = tab_chrome_gap(cell_width);
let right_limit = screen_width as f32 - finite_or(cell_width, 12.0) * 2.0;
let min_width = tab_chrome_min_width(cell_width);
let max_width = tab_chrome_max_width(cell_width);
let mut x = tab_chrome_start_x(cell_width);
let mut rects = Vec::new();
for (tab_index, tab) in tabs.iter().take(TAB_CHROME_MAX_VISIBLE).enumerate() {
let label = tab_chrome_label(tab);
let ideal_width = label.chars().count() as f32 * finite_or(cell_width, 12.0)
+ close_slot_width
+ padding_x * 2.0;
let width = ideal_width.clamp(min_width, max_width);
if x + width > right_limit {
break;
}
rects.push(TabChromeRect {
tab_index,
x,
y: top,
w: width,
h: height,
});
x += width + gap;
}
rects
}
fn tab_chrome_hit_at_position(
cell_width: f32,
cell_height: f32,
screen_width: u32,
tabs: &[RenderTab<'_>],
x: f32,
y: f32,
) -> Option<TabChromeHit> {
tab_chrome_rects(cell_width, cell_height, screen_width, tabs)
.into_iter()
.find_map(|rect| {
let inside_tab =
x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h;
if !inside_tab {
return None;
}
let close = tab_chrome_close_rect(&rect, cell_width);
if x >= close.x && x < close.x + close.w && y >= close.y && y < close.y + close.h {
Some(TabChromeHit::Close(rect.tab_index))
} else {
Some(TabChromeHit::Select(rect.tab_index))
}
})
}
fn srgb_byte_to_linear(byte: u8) -> f32 {
let value = byte as f32 / 255.0;
if value <= 0.04045 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
}
fn push_rect_vertices(out: &mut Vec<f32>, rect: Rect, screen_w: u32, screen_h: u32) {
if screen_w == 0
|| screen_h == 0
|| rect.w <= 0.0
|| rect.h <= 0.0
|| !rect.x.is_finite()
|| !rect.y.is_finite()
|| !rect.w.is_finite()
|| !rect.h.is_finite()
{
return;
}
let screen_w = screen_w as f32;
let screen_h = screen_h as f32;
let left = 2.0 * rect.x / screen_w - 1.0;
let right = 2.0 * (rect.x + rect.w) / screen_w - 1.0;
let top = 1.0 - 2.0 * rect.y / screen_h;
let bottom = 1.0 - 2.0 * (rect.y + rect.h) / screen_h;
out.reserve(RECT_VERTICES_PER_QUAD * RECT_FLOATS_PER_VERTEX);
for (x, y) in [
(left, top),
(right, top),
(left, bottom),
(right, top),
(right, bottom),
(left, bottom),
] {
out.extend_from_slice(&[
x,
y,
rect.color[0],
rect.color[1],
rect.color[2],
rect.color[3],
]);
}
}
fn expand_user_path(path: &str) -> PathBuf {
let trimmed = path.trim();
if let Some(rest) = trimmed.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return Path::new(&home).join(rest);
}
PathBuf::from(trimmed)
}
fn background_vertices(
image_width: u32,
image_height: u32,
target_width: u32,
target_height: u32,
opacity: f32,
mode: BackgroundImageMode,
) -> [f32; BG_FLOATS_PER_VERTEX * BG_VERTICES_PER_QUAD] {
let image_aspect = image_width.max(1) as f32 / image_height.max(1) as f32;
let target_aspect = target_width.max(1) as f32 / target_height.max(1) as f32;
let opacity = finite_or(opacity, 0.18).clamp(0.0, 1.0);
let (left, right, top, bottom, u0, u1, v0, v1) = match mode {
BackgroundImageMode::Stretch => (-1.0, 1.0, 1.0, -1.0, 0.0, 1.0, 0.0, 1.0),
BackgroundImageMode::Contain => {
if image_aspect > target_aspect {
let height = target_aspect / image_aspect;
(-1.0, 1.0, height, -height, 0.0, 1.0, 0.0, 1.0)
} else {
let width = image_aspect / target_aspect;
(-width, width, 1.0, -1.0, 0.0, 1.0, 0.0, 1.0)
}
}
BackgroundImageMode::Cover => {
if image_aspect > target_aspect {
let visible_width = target_aspect / image_aspect;
let pad = (1.0 - visible_width) * 0.5;
(-1.0, 1.0, 1.0, -1.0, pad, 1.0 - pad, 0.0, 1.0)
} else {
let visible_height = image_aspect / target_aspect;
let pad = (1.0 - visible_height) * 0.5;
(-1.0, 1.0, 1.0, -1.0, 0.0, 1.0, pad, 1.0 - pad)
}
}
};
[
left, top, u0, v0, opacity, right, top, u1, v0, opacity, left, bottom, u0, v1, opacity,
right, top, u1, v0, opacity, right, bottom, u1, v1, opacity, left, bottom, u0, v1, opacity,
]
}
fn attrs_for_family(font_family: &str) -> glyphon::Attrs<'_> {
let family = font_family.trim();
if family.is_empty() {
glyphon::Attrs::new().family(glyphon::Family::Monospace)
} else {
glyphon::Attrs::new().family(glyphon::Family::Name(family))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StyleKey {
fg: CellColor,
bg: CellColor,
attrs: u16,
}
#[cfg(test)]
fn style_key(cell: &Cell) -> StyleKey {
style_key_for_theme(cell, &RenderTheme::default())
}
fn style_key_for_theme(cell: &Cell, theme: &RenderTheme) -> StyleKey {
let (fg, _) = effective_cell_colors(cell, theme);
StyleKey {
fg: rgb_to_cell_color(fg),
bg: cell.bg,
attrs: cell.attrs,
}
}
fn is_cell_positioned_text(cell: &Cell) -> bool {
!cell.continuation && cell.c != ' ' && cell.display_width() > 1
}
fn render_style_key(cell: &Cell, selected: bool, cursor: bool, theme: &RenderTheme) -> StyleKey {
if cursor {
return StyleKey {
fg: color_rgb(
((theme.cursor_foreground >> 16) & 0xff) as u8,
((theme.cursor_foreground >> 8) & 0xff) as u8,
(theme.cursor_foreground & 0xff) as u8,
),
bg: cell.bg,
attrs: cell.attrs & !(ATTR_INVERSE | ATTR_HIDDEN),
};
}
if selected {
return StyleKey {
fg: color_rgb(
((theme.selection_foreground >> 16) & 0xff) as u8,
((theme.selection_foreground >> 8) & 0xff) as u8,
(theme.selection_foreground & 0xff) as u8,
),
bg: cell.bg,
attrs: cell.attrs & !ATTR_HIDDEN,
};
}
style_key_for_theme(cell, theme)
}
fn render_cluster_style_key(
cells: &[Cell],
row: usize,
col: usize,
selection: &Selection,
cursor_span: Option<(usize, usize)>,
theme: &RenderTheme,
) -> StyleKey {
let (start, width) = cursor_cell_span(cells, col);
let end = (start + width).min(cells.len());
let selected = (start..end).any(|cell_col| selection.contains(row, cell_col));
let cursor = cursor_span.is_some_and(|(cursor_start, cursor_width)| {
ranges_overlap(start, end, cursor_start, cursor_start + cursor_width)
});
render_style_key(&cells[start], selected, cursor, theme)
}
fn ranges_overlap(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool {
a_start < b_end && b_start < a_end
}
fn block_cursor_text_span(
cursor: &CursorVisual,
row: usize,
cells: &[Cell],
) -> Option<(usize, usize)> {
(cursor.visible
&& cursor.style == CursorStyle::Block
&& cursor.row == row
&& cursor.col < cells.len())
.then(|| cursor_cell_span(cells, cursor.col))
}
fn cursor_cell_span(cells: &[Cell], col: usize) -> (usize, usize) {
if cells.is_empty() || col >= cells.len() {
return (col, 1);
}
let mut start = col;
while start > 0 && cells[start].continuation {
start -= 1;
}
let width = cells[start]
.display_width()
.max(1)
.min(cells.len().saturating_sub(start));
(start, width)
}
fn cursor_visual_cell_span(cells: &[Cell], col: usize) -> (usize, usize) {
let (start, width) = cursor_cell_span(cells, col);
if start < cells.len() && cell_has_emoji_modifier(&cells[start]) {
let visual_col = col.clamp(start, start + width.saturating_sub(1));
(visual_col, 1)
} else {
(start, width)
}
}
fn cell_has_emoji_modifier(cell: &Cell) -> bool {
cell.extra
.as_deref()
.is_some_and(|text| text.chars().any(is_emoji_modifier))
}
fn attrs_for_style<'a>(
base: &glyphon::Attrs<'a>,
key: StyleKey,
theme: &RenderTheme,
) -> glyphon::Attrs<'a> {
let mut attrs = base.clone().color(to_glyphon_color(key.fg, theme));
if key.attrs & ATTR_BOLD != 0 {
attrs = attrs.weight(glyphon::Weight::BOLD);
}
if key.attrs & ATTR_DIM != 0 && key.attrs & ATTR_BOLD == 0 {
attrs = attrs.weight(glyphon::Weight::LIGHT);
}
if key.attrs & ATTR_ITALIC != 0 {
attrs = attrs.style(glyphon::Style::Italic);
}
attrs
}
fn compute_cell_dimensions(scale_factor: f64, settings: &RenderSettings) -> (f32, f32, f32) {
let fsz = (settings.sanitized_font_size() * scale_factor as f32).ceil();
let line_height = settings.sanitized_line_height();
((fsz * 0.6).ceil(), (fsz * line_height).ceil(), fsz)
}
fn finite_or(value: f32, fallback: f32) -> f32 {
if value.is_finite() { value } else { fallback }
}
fn grid_dimensions_from_cell_size(
width: u32,
height: u32,
cw: f32,
ch: f32,
top_padding_lines: f32,
) -> (usize, usize) {
let padding_x = cw * 2.0;
let padding_top = ch * sanitized_top_padding_lines(top_padding_lines);
let padding_bottom = ch;
let cols = (((width as f32) - padding_x * 2.0) / cw).max(20.0) as usize;
let rows = (((height as f32) - padding_top - padding_bottom) / ch).max(5.0) as usize;
(cols, rows)
}
fn sanitized_top_padding_lines(lines: f32) -> f32 {
finite_or(lines, 1.0).clamp(1.0, 6.0)
}
#[cfg(test)]
mod tests {
use super::{
BackgroundImageMode, CURSOR_FS, CURSOR_VS, DEFAULT_INVERSE_BG, RECT_FLOATS_PER_VERTEX,
RECT_VERTICES_PER_QUAD, Rect, RenderTab, RenderTheme, TabChromeHit, background_color,
background_vertices, cursor_cell_span, cursor_visual_cell_span, inverse_background_color,
is_cell_positioned_text, mix_rgb, push_rect_vertices, render_cluster_style_key,
render_style_key, rgb_u32_to_f32, row_background_end, selection_cell_span,
selection_rect_span, style_key, style_key_for_theme, tab_chrome_hit_at_position,
tab_chrome_rects, tab_chrome_start_x, wgpu_color_from_rgb,
};
use crate::terminal::cell::{ATTR_HIDDEN, ATTR_INVERSE, color_rgb};
use crate::terminal::{Cell, Selection};
#[test]
fn rect_shaders_pass_vertex_color_to_fragment() {
assert!(CURSOR_VS.contains("@location(1) color: vec4<f32>"));
assert!(CURSOR_FS.contains("return input.color;"));
assert!(!CURSOR_FS.contains("uni."));
}
#[test]
fn rect_vertices_expand_to_two_triangles() {
let mut vertices = Vec::new();
push_rect_vertices(
&mut vertices,
Rect {
x: 10.0,
y: 20.0,
w: 30.0,
h: 40.0,
color: [0.1, 0.2, 0.3, 0.4],
},
100,
100,
);
assert_eq!(
vertices.len(),
RECT_FLOATS_PER_VERTEX * RECT_VERTICES_PER_QUAD
);
assert_eq!(&vertices[2..6], &[0.1, 0.2, 0.3, 0.4]);
}
#[test]
fn background_cover_crops_wide_images_by_uv() {
let vertices = background_vertices(2000, 1000, 1000, 1000, 0.2, BackgroundImageMode::Cover);
assert_eq!(vertices[0], -1.0);
assert_eq!(vertices[1], 1.0);
assert_eq!(vertices[4], 0.2);
assert!(vertices[2] > 0.0);
assert!(vertices[7] < 1.0);
}
#[test]
fn background_contain_preserves_image_aspect_in_geometry() {
let vertices =
background_vertices(2000, 1000, 1000, 1000, 0.3, BackgroundImageMode::Contain);
assert_eq!(vertices[0], -1.0);
assert_eq!(vertices[5], 1.0);
assert!(vertices[1] < 1.0);
assert!(vertices[11] > -1.0);
assert_eq!(vertices[4], 0.3);
}
#[test]
fn configured_hex_colors_are_converted_to_linear_output() {
let clear = wgpu_color_from_rgb(0x02050A);
assert!(clear.r < 0.002);
assert!(clear.g < 0.002);
assert!(clear.b < 0.004);
let rect = rgb_u32_to_f32(0xE6E6E6, 0.82);
assert!((rect[0] - 0.791).abs() < 0.01);
assert!((rect[3] - 0.82).abs() < f32::EPSILON);
}
#[test]
fn tab_chrome_color_mixing_is_stable() {
assert_eq!(mix_rgb(0x000000, 0xFFFFFF, 0.5), 0x808080);
assert_eq!(mix_rgb(0x112233, 0x445566, 0.0), 0x112233);
assert_eq!(mix_rgb(0x112233, 0x445566, 1.0), 0x445566);
}
#[test]
fn tab_chrome_starts_after_macos_window_controls() {
assert!(tab_chrome_start_x(14.0) >= 170.0);
assert_eq!(tab_chrome_start_x(24.0), 228.0);
}
#[test]
fn tab_chrome_rects_map_clickable_tab_indices() {
let titles = ["zsh", "vim", "ssh"];
let tabs = titles
.iter()
.enumerate()
.map(|(index, title)| RenderTab {
number: index + 1,
title,
active: index == 1,
})
.collect::<Vec<_>>();
let rects = tab_chrome_rects(14.0, 22.0, 800, &tabs);
assert_eq!(rects.len(), 3);
assert_eq!(rects[0].tab_index, 0);
assert_eq!(rects[1].tab_index, 1);
assert!(rects[0].x >= 170.0);
assert!(rects[1].x > rects[0].x + rects[0].w);
}
#[test]
fn tab_chrome_hit_test_distinguishes_close_and_select() {
let tabs = ["zsh", "vim"]
.iter()
.enumerate()
.map(|(index, title)| RenderTab {
number: index + 1,
title,
active: index == 0,
})
.collect::<Vec<_>>();
let rects = tab_chrome_rects(14.0, 22.0, 800, &tabs);
let first = rects[0];
assert_eq!(
tab_chrome_hit_at_position(14.0, 22.0, 800, &tabs, first.x + 18.0, first.y + 10.0),
Some(TabChromeHit::Close(0))
);
assert_eq!(
tab_chrome_hit_at_position(
14.0,
22.0,
800,
&tabs,
first.x + first.w - 8.0,
first.y + 10.0
),
Some(TabChromeHit::Select(0))
);
}
#[test]
fn tab_chrome_hit_test_ignores_overflow_hidden_tabs() {
let tabs = ["zsh", "vim", "ssh"]
.iter()
.enumerate()
.map(|(index, title)| RenderTab {
number: index + 1,
title,
active: index == 0,
})
.collect::<Vec<_>>();
let rects = tab_chrome_rects(14.0, 22.0, 340, &tabs);
assert_eq!(rects.len(), 1);
assert_eq!(
tab_chrome_hit_at_position(14.0, 22.0, 340, &tabs, 330.0, rects[0].y + 10.0),
None
);
}
#[test]
fn selection_rect_trims_default_row_padding() {
let mut cells = vec![Cell::blank(); 10];
for (idx, ch) in "hello".chars().enumerate() {
cells[idx] = Cell::new(ch);
}
let mut selection = Selection::new();
selection.set_span((0, 0), (0, 9));
assert_eq!(selection_cell_span(&selection, 0, &cells), Some((0, 5)));
assert_eq!(selection_rect_span(&selection, 0, &cells), Some((0, 5)));
}
#[test]
fn inverse_default_background_uses_dark_text() {
let mut cell = Cell::new('x');
cell.attrs = ATTR_INVERSE;
assert_eq!(style_key(&cell).fg, color_rgb(0x02, 0x05, 0x0A));
}
#[test]
fn block_cursor_text_uses_dark_foreground() {
let cell = Cell::new('x');
let theme = RenderTheme::default();
assert_eq!(
render_style_key(&cell, false, true, &theme).fg,
color_rgb(0x02, 0x05, 0x0A)
);
}
#[test]
fn cursor_span_covers_wide_continuation_cluster() {
let mut cells = vec![Cell::blank(); 6];
cells[1] = Cell::new('你');
cells[1].width = 2;
cells[2] = Cell::continuation_of(&cells[1]);
assert_eq!(cursor_cell_span(&cells, 1), (1, 2));
assert_eq!(cursor_cell_span(&cells, 2), (1, 2));
}
#[test]
fn emoji_modifier_cursor_visual_span_tracks_legacy_cells() {
let mut cells = vec![Cell::blank(); 6];
cells[1] = Cell::new('👍');
cells[1].append_to_cluster('🏽');
for col in 2..5 {
cells[col] = Cell::continuation_of(&cells[1]);
}
assert_eq!(cursor_cell_span(&cells, 3), (1, 4));
assert_eq!(cursor_visual_cell_span(&cells, 1), (1, 1));
assert_eq!(cursor_visual_cell_span(&cells, 2), (2, 1));
assert_eq!(cursor_visual_cell_span(&cells, 3), (3, 1));
assert_eq!(cursor_visual_cell_span(&cells, 4), (4, 1));
}
#[test]
fn selection_span_walks_back_over_multi_cell_continuations() {
let mut cells = vec![Cell::blank(); 8];
cells[0] = Cell::new('👍');
cells[0].append_to_cluster('🏽');
for col in 1..4 {
cells[col] = Cell::continuation_of(&cells[0]);
}
let mut selection = Selection::new();
selection.set_span((0, 3), (0, 4));
assert_eq!(selection_cell_span(&selection, 0, &cells), Some((0, 4)));
}
#[test]
fn only_wide_cells_are_positioned_by_terminal_cell() {
let mut emoji = Cell::new('👍');
emoji.append_to_cluster('🏽');
let mut combining = Cell::new('e');
combining.append_combining('\u{0301}');
assert!(!is_cell_positioned_text(&Cell::new('x')));
assert!(!is_cell_positioned_text(&Cell::new(' ')));
assert!(!is_cell_positioned_text(&Cell::new('é')));
assert!(!is_cell_positioned_text(&combining));
assert!(is_cell_positioned_text(&Cell::new('好')));
assert!(is_cell_positioned_text(&emoji));
}
#[test]
fn wide_cluster_style_includes_continuation_selection() {
let mut cells = vec![Cell::blank(); 4];
cells[0] = Cell::new('你');
cells[0].width = 2;
cells[1] = Cell::continuation_of(&cells[0]);
let mut selection = Selection::new();
selection.set_span((0, 1), (0, 1));
let theme = RenderTheme::default();
assert_eq!(
render_cluster_style_key(&cells, 0, 0, &selection, None, &theme).fg,
color_rgb(0xBF, 0xBF, 0xBF)
);
}
#[test]
fn wide_cluster_style_includes_continuation_cursor() {
let mut cells = vec![Cell::blank(); 4];
cells[0] = Cell::new('好');
cells[0].width = 2;
cells[1] = Cell::continuation_of(&cells[0]);
let selection = Selection::new();
let theme = RenderTheme::default();
assert_eq!(
render_cluster_style_key(&cells, 0, 0, &selection, Some((1, 1)), &theme).fg,
color_rgb(0x02, 0x05, 0x0A)
);
}
#[test]
fn inverse_default_background_uses_neutral_fill() {
let mut cell = Cell::new('x');
cell.attrs = ATTR_INVERSE;
let theme = RenderTheme::default();
assert_eq!(inverse_background_color(&cell, &theme), DEFAULT_INVERSE_BG);
assert_eq!(
background_color(&cell, 0, &theme),
Some(rgb_u32_to_f32(DEFAULT_INVERSE_BG, 1.0))
);
}
#[test]
fn inverse_explicit_colors_swap_foreground_and_background() {
let mut cell = Cell::with_attrs(
'x',
crate::terminal::cell::color_ansi(1),
crate::terminal::cell::color_ansi(4),
ATTR_INVERSE,
);
let theme = RenderTheme::default();
assert_eq!(
style_key_for_theme(&cell, &theme).fg,
color_rgb(0x00, 0x00, 0xCC)
);
assert_eq!(inverse_background_color(&cell, &theme), 0xCC0000);
assert_eq!(
background_color(&cell, 0, &theme),
Some(rgb_u32_to_f32(0xCC0000, 1.0))
);
cell.attrs |= ATTR_HIDDEN;
assert_eq!(
style_key_for_theme(&cell, &theme).fg,
color_rgb(0xCC, 0x00, 0x00)
);
}
#[test]
fn row_background_end_trims_trailing_inverse_padding() {
let mut cells = vec![Cell::blank(); 8];
for (idx, ch) in "paste".chars().enumerate() {
cells[idx] = Cell::new(ch);
}
cells[5].attrs = ATTR_INVERSE;
cells[6].attrs = ATTR_INVERSE;
assert_eq!(row_background_end(&cells), 5);
}
#[test]
fn row_background_end_preserves_full_row_inverse_fill() {
let mut cells = vec![Cell::blank(); 8];
cells[0] = Cell::new('s');
for cell in &mut cells {
cell.attrs = ATTR_INVERSE;
}
assert_eq!(row_background_end(&cells), cells.len());
}
}