diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 8728d20..41e0c13 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -1,25 +1,21 @@ -use std::collections::HashMap; use std::rc::Rc; use std::sync::Arc; use app_config::SiteConfig; use leptos::html; use leptos::prelude::*; -use unicode_segmentation::UnicodeSegmentation; -use unicode_width::UnicodeWidthStr; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; -use web_sys::js_sys::{Array, Float32Array, Function}; +use web_sys::js_sys::{Array, Function}; use web_sys::{ - BinaryType, CanvasRenderingContext2d, ClipboardEvent, CompositionEvent, ErrorEvent, Event, - HtmlCanvasElement, HtmlElement, InputEvent, KeyboardEvent, MessageEvent, MouseEvent, - ResizeObserver, ResizeObserverEntry, WebGl2RenderingContext, WebGlBuffer, WebGlProgram, - WebGlShader, WebGlTexture, WebGlUniformLocation, WebSocket, WheelEvent, + BinaryType, ClipboardEvent, CompositionEvent, ErrorEvent, Event, HtmlCanvasElement, + HtmlElement, InputEvent, KeyboardEvent, MessageEvent, MouseEvent, ResizeObserver, + ResizeObserverEntry, WebSocket, WheelEvent, }; use super::core::{ MouseProtocolMode, SearchDirection, TerminalCore, TerminalLink, TerminalRow, - TerminalSearchMatch, TerminalSegment, TerminalStyle, + TerminalSearchMatch, TerminalSegment, }; use super::ime::{clear_ime_textarea, focus_ime_textarea, ime_cursor_cell, ime_ref_text}; use super::keyboard::{ @@ -30,6 +26,10 @@ use super::mouse::{ with_mouse_modifiers, }; use super::protocol::{ClientTerminalMessage, ServerTerminalMessage}; +use super::renderer::{ + CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, RenderCanvasSurfaceRequest, + WebGlRendererState, canvas_render_plan, render_canvas_surface, render_webgl, +}; use super::scrollback::ViewMode; use super::selection::{Selection, SelectionRect, selection_rects}; @@ -353,18 +353,18 @@ enum TerminalSearchRequest { } #[derive(Clone, Debug, PartialEq)] -struct ResolvedTerminalOptions { +pub(super) struct ResolvedTerminalOptions { cols: u16, rows: u16, min_cols: u16, min_rows: u16, scrollback: usize, renderer: Renderer, - font_family: String, - font_size_px: f64, + pub(super) font_family: String, + pub(super) font_size_px: f64, line_height_px: f64, - theme: TerminalTheme, - cursor: TerminalCursorOptions, + pub(super) theme: TerminalTheme, + pub(super) cursor: TerminalCursorOptions, title: String, subtitle: String, show_header: bool, @@ -417,49 +417,6 @@ fn terminal_options_style(options: &ResolvedTerminalOptions) -> String { ) } -#[derive(Clone, Copy, Debug, PartialEq)] -struct CursorShapeRect { - x: f64, - y: f64, - width: f64, - height: f64, -} - -fn cursor_shape_rect( - style: TerminalCursorStyle, - x: f64, - y: f64, - cell_w: f64, - cell_h: f64, -) -> CursorShapeRect { - match style { - TerminalCursorStyle::Block => CursorShapeRect { - x, - y, - width: cell_w, - height: cell_h, - }, - TerminalCursorStyle::Underline => { - let height = (cell_h * 0.15).round().clamp(1.0, 3.0); - CursorShapeRect { - x, - y: y + cell_h - height, - width: cell_w, - height, - } - } - TerminalCursorStyle::Bar => { - let width = (cell_w * 0.18).round().clamp(1.0, 3.0); - CursorShapeRect { - x, - y, - width, - height: cell_h, - } - } - } -} - fn terminal_viewport_for_options(options: &ResolvedTerminalOptions) -> TerminalViewport { TerminalViewport { cols: options.cols, @@ -491,34 +448,6 @@ fn terminal_resize_event(viewport: TerminalViewport) -> TerminalResizeEvent { } } -fn themed_color<'a>(color: &'a str, options: &'a ResolvedTerminalOptions) -> &'a str { - if color == CANVAS_FG { - &options.theme.foreground - } else if color == CANVAS_BG { - &options.theme.background - } else { - color - } -} - -fn terminal_font(style: &TerminalStyle, options: &ResolvedTerminalOptions) -> String { - terminal_font_with_px(style, options.font_size_px, &options.font_family) -} - -fn terminal_font_with_px(style: &TerminalStyle, px: f64, font_family: &str) -> String { - let mut parts = Vec::with_capacity(3); - if style.bold { - parts.push("bold"); - } - if style.italic { - parts.push("italic"); - } - let px = px.max(1.0); - let font_size = format!("{px}px"); - parts.push(&font_size); - format!("{} {}", parts.join(" "), font_family) -} - fn renderer_uses_canvas_surface(renderer: Renderer) -> bool { matches!(renderer, Renderer::WebGl | Renderer::Canvas) } @@ -2061,1035 +1990,6 @@ fn virtual_window( // Render the visible rows using absolute positioning inside a fixed-height // container. // -// --------------------------------------------------------------------------- -// Canvas renderer -// --------------------------------------------------------------------------- - -/// Default terminal foreground / cursor colors, matching `tailwind.css`. -const CANVAS_FG: &str = "#dbeafe"; -const CANVAS_BG: &str = "#0f172a"; - -fn canvas_fill_style(color: &str) -> Option<&str> { - if color == "transparent" { - None - } else { - Some(color) - } -} - -fn canvas_viewport_top_px( - is_live: bool, - scroll_top_px: i32, - total_px: f64, - vp_rows: usize, - line_height_px: f64, -) -> f64 { - if is_live { - (total_px - vp_rows as f64 * line_height_px).max(0.0) - } else { - f64::from(scroll_top_px.max(0)) - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct CanvasDomState { - backing_width: u32, - backing_height: u32, - css_width: i32, - css_height: i32, - top_px: f64, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct CanvasRenderPlan { - dom_state: CanvasDomState, - content_top_px: f64, -} - -impl CanvasRenderPlan { - fn dom_state(self) -> CanvasDomState { - self.dom_state - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct CanvasPlanInput { - css_width: i32, - css_height: i32, - dpr: f64, - is_live: bool, - scroll_top_px: i32, - total_px: f64, - offset_px: f64, - vp_rows: usize, - line_height_px: f64, -} - -fn canvas_render_plan(input: CanvasPlanInput) -> CanvasRenderPlan { - let dpr = input.dpr.max(1.0); - let viewport_top = canvas_viewport_top_px( - input.is_live, - input.scroll_top_px, - input.total_px, - input.vp_rows, - input.line_height_px, - ); - let top_px = if input.is_live { - viewport_top - } else { - f64::from(input.scroll_top_px.max(0)) - }; - - CanvasRenderPlan { - dom_state: CanvasDomState { - backing_width: (f64::from(input.css_width.max(1)) * dpr).ceil() as u32, - backing_height: (f64::from(input.css_height.max(1)) * dpr).ceil() as u32, - css_width: input.css_width.max(1), - css_height: input.css_height.max(1), - top_px, - }, - content_top_px: input.offset_px - viewport_top, - } -} - -fn sync_canvas_dom( - canvas: &HtmlCanvasElement, - next: CanvasDomState, - state: &Rc>>, -) { - let mut current = state.borrow_mut(); - if current.map(|s| s.backing_width) != Some(next.backing_width) { - canvas.set_width(next.backing_width); - } - if current.map(|s| s.backing_height) != Some(next.backing_height) { - canvas.set_height(next.backing_height); - } - if current - .map(|s| { - s.css_width != next.css_width - || s.css_height != next.css_height - || (s.top_px - next.top_px).abs() > 0.5 - }) - .unwrap_or(true) - { - let _ = canvas.set_attribute( - "style", - &format!( - "width:{}px;height:{}px;top:{}px;", - next.css_width, next.css_height, next.top_px - ), - ); - } - *current = Some(next); -} - -fn sync_canvas_bitmap(canvas: &HtmlCanvasElement, next: CanvasDomState) { - if canvas.width() != next.backing_width { - canvas.set_width(next.backing_width); - } - if canvas.height() != next.backing_height { - canvas.set_height(next.backing_height); - } -} - -fn canvas_2d_context(canvas: &HtmlCanvasElement) -> Option { - canvas - .get_context("2d") - .ok() - .flatten()? - .dyn_into::() - .ok() -} - -#[derive(Clone, Copy)] -struct CanvasSurfaceFrame<'a> { - dom_state: CanvasDomState, - dpr: f64, - visible: &'a [TerminalRow], - cell_size: (f64, f64), - content_top_px: f64, - cursor_pos: Option<(usize, usize)>, - options: &'a ResolvedTerminalOptions, -} - -struct RenderCanvasSurfaceRequest<'a> { - canvas_el: &'a HtmlCanvasElement, - frame: CanvasSurfaceFrame<'a>, - canvas_state: &'a Rc>>, -} - -fn render_canvas_surface(request: RenderCanvasSurfaceRequest<'_>) { - sync_canvas_dom( - request.canvas_el, - request.frame.dom_state, - request.canvas_state, - ); - let Some(ctx) = canvas_2d_context(request.canvas_el) else { - return; - }; - let _ = ctx.set_transform(request.frame.dpr, 0.0, 0.0, request.frame.dpr, 0.0, 0.0); - render_canvas(&ctx, request.frame); -} - -const WEBGL_VERTEX_SHADER: &str = r#" -attribute vec2 a_position; -attribute vec2 a_tex_coord; -attribute vec4 a_color; -varying vec2 v_tex_coord; -varying vec4 v_color; - -void main() { - gl_Position = vec4(a_position, 0.0, 1.0); - v_tex_coord = a_tex_coord; - v_color = a_color; -} -"#; - -const WEBGL_FRAGMENT_SHADER: &str = r#" -precision mediump float; -uniform sampler2D u_texture; -varying vec2 v_tex_coord; -varying vec4 v_color; - -void main() { - gl_FragColor = texture2D(u_texture, v_tex_coord) * v_color; -} -"#; - -const WEBGL_QUAD_VERTEX_COUNT: i32 = 6; -const WEBGL_VERTEX_FLOATS: usize = 8; -const WEBGL_QUAD_STRIDE_BYTES: i32 = (WEBGL_VERTEX_FLOATS * 4) as i32; -const WEBGL_QUAD_TEXCOORD_OFFSET_BYTES: i32 = 2 * 4; -const WEBGL_QUAD_COLOR_OFFSET_BYTES: i32 = 4 * 4; -const GLYPH_ATLAS_SIZE: u32 = 2048; -const GLYPH_ATLAS_PADDING: u32 = 2; - -fn webgl_quad_vertices() -> [f32; WEBGL_VERTEX_FLOATS * 6] { - [ - -1.0, -1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, // - 1.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, // - -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, // - -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, // - 1.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, // - 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, - ] -} - -fn webgl_shader_sources() -> (&'static str, &'static str) { - (WEBGL_VERTEX_SHADER, WEBGL_FRAGMENT_SHADER) -} - -fn webgl_texture_filter() -> i32 { - WebGl2RenderingContext::NEAREST as i32 -} - -struct WebGlRendererState { - source_canvas: HtmlCanvasElement, - source_ctx: CanvasRenderingContext2d, - atlas_canvas: HtmlCanvasElement, - atlas_ctx: CanvasRenderingContext2d, - atlas: GlyphAtlas, - gl: WebGl2RenderingContext, - program: WebGlProgram, - atlas_texture: WebGlTexture, - fallback_texture: WebGlTexture, - buffer: WebGlBuffer, - position_location: u32, - tex_coord_location: u32, - color_location: u32, - sampler_location: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct GlyphKey { - text: String, - bold: bool, - italic: bool, - cols: usize, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct GlyphAtlasEntry { - x: u32, - y: u32, - width: u32, - height: u32, - cols: usize, -} - -#[derive(Debug)] -struct GlyphAtlas { - size: u32, - padding: u32, - next_x: u32, - next_y: u32, - row_height: u32, - entries: HashMap, - dirty: bool, -} - -impl GlyphAtlas { - fn new(size: u32, padding: u32) -> Self { - Self { - size, - padding, - next_x: padding, - next_y: padding, - row_height: 0, - entries: HashMap::new(), - dirty: true, - } - } - - fn reset(&mut self) { - self.next_x = self.padding; - self.next_y = self.padding; - self.row_height = 0; - self.entries.clear(); - self.dirty = true; - } - - fn get(&self, key: &GlyphKey) -> Option { - self.entries.get(key).copied() - } - - fn reserve(&mut self, key: GlyphKey, width: u32, height: u32) -> Option { - if let Some(entry) = self.get(&key) { - return Some(entry); - } - let width = width.max(1); - let height = height.max(1); - let padded_width = width + self.padding * 2; - let padded_height = height + self.padding * 2; - if padded_width > self.size || padded_height > self.size { - return None; - } - if self.next_x + padded_width > self.size { - self.next_x = self.padding; - self.next_y += self.row_height; - self.row_height = 0; - } - if self.next_y + padded_height > self.size { - return None; - } - - let entry = GlyphAtlasEntry { - x: self.next_x + self.padding, - y: self.next_y + self.padding, - width, - height, - cols: key.cols, - }; - self.next_x += padded_width; - self.row_height = self.row_height.max(padded_height); - self.entries.insert(key, entry); - self.dirty = true; - Some(entry) - } -} - -#[derive(Clone, Debug, PartialEq)] -struct GlyphCell { - key: GlyphKey, - row: usize, - col: usize, - fg: [f32; 4], -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct GlyphQuad { - x: f64, - y: f64, - width: f64, - height: f64, - uv: [f64; 4], - color: [f32; 4], -} - -fn split_segment_glyphs( - segment: &TerminalSegment, - row: usize, - start_col: usize, - options: &ResolvedTerminalOptions, -) -> Option> { - let mut cells = Vec::new(); - let mut col = start_col; - let mut used_cols = 0usize; - for grapheme in UnicodeSegmentation::graphemes(segment.text.as_str(), true) { - let mut cols = UnicodeWidthStr::width(grapheme); - if cols == 0 { - cols = 1; - } - if used_cols + cols > segment.cols { - return None; - } - let fg = if segment.is_cursor && options.cursor.style == TerminalCursorStyle::Block { - parse_css_rgb(&options.theme.cursor_foreground)? - } else { - parse_css_rgb(themed_color(&segment.style.color, options))? - }; - cells.push(GlyphCell { - key: GlyphKey { - text: grapheme.to_owned(), - bold: segment.style.bold, - italic: segment.style.italic, - cols, - }, - row, - col, - fg, - }); - col += cols; - used_cols += cols; - } - if used_cols == segment.cols { - Some(cells) - } else { - None - } -} - -fn collect_glyph_cells( - visible: &[TerminalRow], - options: &ResolvedTerminalOptions, -) -> Option> { - let mut cells = Vec::new(); - for (row_idx, row) in visible.iter().enumerate() { - let mut col = 0usize; - for segment in &row.segments { - cells.extend(split_segment_glyphs(segment, row_idx, col, options)?); - col += segment.cols; - } - } - Some(cells) -} - -fn parse_css_rgb(color: &str) -> Option<[f32; 4]> { - let color = color.trim(); - if color == "transparent" { - return Some([0.0, 0.0, 0.0, 0.0]); - } - if let Some(hex) = color.strip_prefix('#') - && hex.len() == 6 - { - let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0; - let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0; - let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0; - return Some([r, g, b, 1.0]); - } - if let Some(parts) = color - .strip_prefix("rgb(") - .and_then(|s| s.strip_suffix(')')) - .map(|s| s.split(',').collect::>()) - && parts.len() == 3 - { - let r = parts[0].trim().parse::().ok()? / 255.0; - let g = parts[1].trim().parse::().ok()? / 255.0; - let b = parts[2].trim().parse::().ok()? / 255.0; - return Some([r, g, b, 1.0]); - } - None -} - -fn push_quad(vertices: &mut Vec, quad: GlyphQuad, dom_state: CanvasDomState) { - let w = dom_state.css_width as f64; - let h = dom_state.css_height as f64; - let x1 = (quad.x / w * 2.0 - 1.0) as f32; - let x2 = ((quad.x + quad.width) / w * 2.0 - 1.0) as f32; - let y1 = (1.0 - quad.y / h * 2.0) as f32; - let y2 = (1.0 - (quad.y + quad.height) / h * 2.0) as f32; - let [u1, v1, u2, v2] = quad.uv.map(|v| v as f32); - let [r, g, b, a] = quad.color; - let data = [ - x1, y2, u1, v2, r, g, b, a, // - x2, y2, u2, v2, r, g, b, a, // - x1, y1, u1, v1, r, g, b, a, // - x1, y1, u1, v1, r, g, b, a, // - x2, y2, u2, v2, r, g, b, a, // - x2, y1, u2, v1, r, g, b, a, - ]; - vertices.extend_from_slice(&data); -} - -fn glyph_uv(entry: GlyphAtlasEntry, atlas_size: u32) -> [f64; 4] { - let size = f64::from(atlas_size); - [ - f64::from(entry.x) / size, - 1.0 - f64::from(entry.y) / size, - f64::from(entry.x + entry.width) / size, - 1.0 - f64::from(entry.y + entry.height) / size, - ] -} - -fn render_webgl( - canvas: &HtmlCanvasElement, - frame: CanvasSurfaceFrame<'_>, - canvas_state: &Rc>>, - webgl_state: &Rc>>, -) -> Result<(), String> { - sync_canvas_dom(canvas, frame.dom_state, canvas_state); - let mut state_ref = webgl_state.borrow_mut(); - if state_ref.is_none() { - *state_ref = Some(create_webgl_renderer(canvas)?); - } - let state = state_ref - .as_mut() - .ok_or_else(|| "missing WebGL renderer state".to_owned())?; - - sync_canvas_bitmap(&state.source_canvas, frame.dom_state); - let _ = state - .source_ctx - .set_transform(frame.dpr, 0.0, 0.0, frame.dpr, 0.0, 0.0); - render_canvas_backdrop(&state.source_ctx, frame); - let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else { - render_canvas(&state.source_ctx, frame); - draw_webgl_texture_frame(state, frame.dom_state)?; - return Ok(()); - }; - - draw_webgl_texture_frame(state, frame.dom_state)?; - if draw_webgl_glyphs(state, frame, &cells).is_err() { - render_canvas(&state.source_ctx, frame); - draw_webgl_texture_frame(state, frame.dom_state)?; - } - Ok(()) -} - -fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result { - let gl = canvas - .get_context("webgl2") - .map_err(|_| "failed to request WebGL2 context".to_owned())? - .ok_or_else(|| "WebGL2 context is unavailable".to_owned())? - .dyn_into::() - .map_err(|_| "context is not WebGL2".to_owned())?; - - let (vertex_source, fragment_source) = webgl_shader_sources(); - let vertex_shader = - compile_webgl_shader(&gl, WebGl2RenderingContext::VERTEX_SHADER, vertex_source)?; - let fragment_shader = compile_webgl_shader( - &gl, - WebGl2RenderingContext::FRAGMENT_SHADER, - fragment_source, - )?; - let program = link_webgl_program(&gl, &vertex_shader, &fragment_shader)?; - - let position_location = gl.get_attrib_location(&program, "a_position"); - let tex_coord_location = gl.get_attrib_location(&program, "a_tex_coord"); - let color_location = gl.get_attrib_location(&program, "a_color"); - if position_location < 0 || tex_coord_location < 0 || color_location < 0 { - return Err("WebGL shader attribute locations are missing".to_owned()); - } - let sampler_location = gl.get_uniform_location(&program, "u_texture"); - - let buffer = gl - .create_buffer() - .ok_or_else(|| "failed to create WebGL vertex buffer".to_owned())?; - gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&buffer)); - let vertices = webgl_quad_vertices(); - unsafe { - let vertex_array = Float32Array::view(&vertices); - gl.buffer_data_with_array_buffer_view( - WebGl2RenderingContext::ARRAY_BUFFER, - vertex_array.as_ref(), - WebGl2RenderingContext::STATIC_DRAW, - ); - } - - let atlas_texture = gl - .create_texture() - .ok_or_else(|| "failed to create WebGL atlas texture".to_owned())?; - configure_webgl_texture(&gl, &atlas_texture); - let fallback_texture = gl - .create_texture() - .ok_or_else(|| "failed to create WebGL fallback texture".to_owned())?; - configure_webgl_texture(&gl, &fallback_texture); - gl.pixel_storei(WebGl2RenderingContext::UNPACK_FLIP_Y_WEBGL, 1); - gl.pixel_storei(WebGl2RenderingContext::UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1); - gl.enable(WebGl2RenderingContext::BLEND); - gl.blend_func( - WebGl2RenderingContext::ONE, - WebGl2RenderingContext::ONE_MINUS_SRC_ALPHA, - ); - gl.disable(WebGl2RenderingContext::DEPTH_TEST); - gl.disable(WebGl2RenderingContext::CULL_FACE); - - let source_canvas = document() - .create_element("canvas") - .map_err(|_| "failed to create source canvas element".to_owned())? - .dyn_into::() - .map_err(|_| "source element is not a canvas".to_owned())?; - let source_ctx = canvas_2d_context(&source_canvas) - .ok_or_else(|| "failed to create source Canvas 2D context".to_owned())?; - let atlas_canvas = document() - .create_element("canvas") - .map_err(|_| "failed to create atlas canvas element".to_owned())? - .dyn_into::() - .map_err(|_| "atlas element is not a canvas".to_owned())?; - atlas_canvas.set_width(GLYPH_ATLAS_SIZE); - atlas_canvas.set_height(GLYPH_ATLAS_SIZE); - let atlas_ctx = canvas_2d_context(&atlas_canvas) - .ok_or_else(|| "failed to create atlas Canvas 2D context".to_owned())?; - atlas_ctx.clear_rect( - 0.0, - 0.0, - f64::from(GLYPH_ATLAS_SIZE), - f64::from(GLYPH_ATLAS_SIZE), - ); - - Ok(WebGlRendererState { - source_canvas, - source_ctx, - atlas_canvas, - atlas_ctx, - atlas: GlyphAtlas::new(GLYPH_ATLAS_SIZE, GLYPH_ATLAS_PADDING), - gl, - program, - atlas_texture, - fallback_texture, - buffer, - position_location: position_location as u32, - tex_coord_location: tex_coord_location as u32, - color_location: color_location as u32, - sampler_location, - }) -} - -fn configure_webgl_texture(gl: &WebGl2RenderingContext, texture: &WebGlTexture) { - gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(texture)); - gl.tex_parameteri( - WebGl2RenderingContext::TEXTURE_2D, - WebGl2RenderingContext::TEXTURE_MIN_FILTER, - webgl_texture_filter(), - ); - gl.tex_parameteri( - WebGl2RenderingContext::TEXTURE_2D, - WebGl2RenderingContext::TEXTURE_MAG_FILTER, - webgl_texture_filter(), - ); - gl.tex_parameteri( - WebGl2RenderingContext::TEXTURE_2D, - WebGl2RenderingContext::TEXTURE_WRAP_S, - WebGl2RenderingContext::CLAMP_TO_EDGE as i32, - ); - gl.tex_parameteri( - WebGl2RenderingContext::TEXTURE_2D, - WebGl2RenderingContext::TEXTURE_WRAP_T, - WebGl2RenderingContext::CLAMP_TO_EDGE as i32, - ); -} - -fn compile_webgl_shader( - gl: &WebGl2RenderingContext, - shader_type: u32, - source: &str, -) -> Result { - let shader = gl - .create_shader(shader_type) - .ok_or_else(|| "failed to create WebGL shader".to_owned())?; - gl.shader_source(&shader, source); - gl.compile_shader(&shader); - if gl - .get_shader_parameter(&shader, WebGl2RenderingContext::COMPILE_STATUS) - .as_bool() - .unwrap_or(false) - { - Ok(shader) - } else { - Err(gl - .get_shader_info_log(&shader) - .unwrap_or_else(|| "WebGL shader compile failed".to_owned())) - } -} - -fn link_webgl_program( - gl: &WebGl2RenderingContext, - vertex_shader: &WebGlShader, - fragment_shader: &WebGlShader, -) -> Result { - let program = gl - .create_program() - .ok_or_else(|| "failed to create WebGL program".to_owned())?; - gl.attach_shader(&program, vertex_shader); - gl.attach_shader(&program, fragment_shader); - gl.link_program(&program); - if gl - .get_program_parameter(&program, WebGl2RenderingContext::LINK_STATUS) - .as_bool() - .unwrap_or(false) - { - Ok(program) - } else { - Err(gl - .get_program_info_log(&program) - .unwrap_or_else(|| "WebGL program link failed".to_owned())) - } -} - -fn bind_webgl_vertex_layout(gl: &WebGl2RenderingContext, state: &WebGlRendererState) { - gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&state.buffer)); - gl.enable_vertex_attrib_array(state.position_location); - gl.vertex_attrib_pointer_with_i32( - state.position_location, - 2, - WebGl2RenderingContext::FLOAT, - false, - WEBGL_QUAD_STRIDE_BYTES, - 0, - ); - gl.enable_vertex_attrib_array(state.tex_coord_location); - gl.vertex_attrib_pointer_with_i32( - state.tex_coord_location, - 2, - WebGl2RenderingContext::FLOAT, - false, - WEBGL_QUAD_STRIDE_BYTES, - WEBGL_QUAD_TEXCOORD_OFFSET_BYTES, - ); - gl.enable_vertex_attrib_array(state.color_location); - gl.vertex_attrib_pointer_with_i32( - state.color_location, - 4, - WebGl2RenderingContext::FLOAT, - false, - WEBGL_QUAD_STRIDE_BYTES, - WEBGL_QUAD_COLOR_OFFSET_BYTES, - ); -} - -fn upload_webgl_vertices(gl: &WebGl2RenderingContext, vertices: &[f32]) { - unsafe { - let vertex_array = Float32Array::view(vertices); - gl.buffer_data_with_array_buffer_view( - WebGl2RenderingContext::ARRAY_BUFFER, - vertex_array.as_ref(), - WebGl2RenderingContext::DYNAMIC_DRAW, - ); - } -} - -fn draw_webgl_texture_frame( - state: &WebGlRendererState, - dom_state: CanvasDomState, -) -> Result<(), String> { - let gl = &state.gl; - gl.viewport( - 0, - 0, - dom_state.backing_width as i32, - dom_state.backing_height as i32, - ); - gl.clear_color(0.0, 0.0, 0.0, 0.0); - gl.clear(WebGl2RenderingContext::COLOR_BUFFER_BIT); - gl.use_program(Some(&state.program)); - - bind_webgl_vertex_layout(gl, state); - upload_webgl_vertices(gl, &webgl_quad_vertices()); - - gl.active_texture(WebGl2RenderingContext::TEXTURE0); - gl.bind_texture( - WebGl2RenderingContext::TEXTURE_2D, - Some(&state.fallback_texture), - ); - gl.uniform1i(state.sampler_location.as_ref(), 0); - gl.tex_image_2d_with_u32_and_u32_and_html_canvas_element( - WebGl2RenderingContext::TEXTURE_2D, - 0, - WebGl2RenderingContext::RGBA as i32, - WebGl2RenderingContext::RGBA, - WebGl2RenderingContext::UNSIGNED_BYTE, - &state.source_canvas, - ) - .map_err(|_| "failed to upload source canvas to WebGL fallback texture".to_owned())?; - gl.draw_arrays( - WebGl2RenderingContext::TRIANGLES, - 0, - WEBGL_QUAD_VERTEX_COUNT, - ); - Ok(()) -} - -fn draw_webgl_glyphs( - state: &mut WebGlRendererState, - frame: CanvasSurfaceFrame<'_>, - cells: &[GlyphCell], -) -> Result<(), String> { - ensure_glyphs_in_atlas(state, frame, cells)?; - if state.atlas.dirty { - state.gl.bind_texture( - WebGl2RenderingContext::TEXTURE_2D, - Some(&state.atlas_texture), - ); - state - .gl - .tex_image_2d_with_u32_and_u32_and_html_canvas_element( - WebGl2RenderingContext::TEXTURE_2D, - 0, - WebGl2RenderingContext::RGBA as i32, - WebGl2RenderingContext::RGBA, - WebGl2RenderingContext::UNSIGNED_BYTE, - &state.atlas_canvas, - ) - .map_err(|_| "failed to upload glyph atlas texture".to_owned())?; - state.atlas.dirty = false; - } - - let mut vertices = Vec::with_capacity(cells.len() * WEBGL_VERTEX_FLOATS * 6); - for cell in cells { - let Some(entry) = state.atlas.get(&cell.key) else { - continue; - }; - let x = cell.col as f64 * frame.cell_size.0; - let y = frame.content_top_px + cell.row as f64 * frame.cell_size.1; - push_quad( - &mut vertices, - GlyphQuad { - x, - y, - width: entry.cols as f64 * frame.cell_size.0, - height: frame.cell_size.1, - uv: glyph_uv(entry, state.atlas.size), - color: cell.fg, - }, - frame.dom_state, - ); - } - if vertices.is_empty() { - return Ok(()); - } - - let gl = &state.gl; - gl.use_program(Some(&state.program)); - bind_webgl_vertex_layout(gl, state); - upload_webgl_vertices(gl, &vertices); - gl.active_texture(WebGl2RenderingContext::TEXTURE0); - gl.bind_texture( - WebGl2RenderingContext::TEXTURE_2D, - Some(&state.atlas_texture), - ); - gl.uniform1i(state.sampler_location.as_ref(), 0); - gl.draw_arrays( - WebGl2RenderingContext::TRIANGLES, - 0, - (vertices.len() / WEBGL_VERTEX_FLOATS) as i32, - ); - Ok(()) -} - -fn ensure_glyphs_in_atlas( - state: &mut WebGlRendererState, - frame: CanvasSurfaceFrame<'_>, - cells: &[GlyphCell], -) -> Result<(), String> { - for attempt in 0..2 { - if attempt == 1 { - state.atlas.reset(); - state.atlas_ctx.clear_rect( - 0.0, - 0.0, - f64::from(state.atlas.size), - f64::from(state.atlas.size), - ); - } - let mut ok = true; - for cell in cells { - if state.atlas.get(&cell.key).is_none() - && draw_glyph_into_atlas(state, frame, &cell.key)?.is_none() - { - ok = false; - break; - } - } - if ok { - return Ok(()); - } - } - Err("glyph atlas is full".to_owned()) -} - -fn draw_glyph_into_atlas( - state: &mut WebGlRendererState, - frame: CanvasSurfaceFrame<'_>, - key: &GlyphKey, -) -> Result, String> { - let dpr = frame.dpr.max(1.0); - let width = (key.cols as f64 * frame.cell_size.0 * dpr).ceil() as u32; - let height = (frame.cell_size.1 * dpr).ceil() as u32; - let Some(entry) = state.atlas.reserve(key.clone(), width, height) else { - return Ok(None); - }; - let style = TerminalStyle { - color: "#ffffff".to_owned(), - background: "transparent".to_owned(), - bold: key.bold, - italic: key.italic, - underline: false, - }; - let ctx = &state.atlas_ctx; - ctx.save(); - ctx.begin_path(); - ctx.rect( - f64::from(entry.x), - f64::from(entry.y), - f64::from(entry.width), - f64::from(entry.height), - ); - ctx.clip(); - ctx.set_fill_style_str("rgba(0,0,0,0)"); - ctx.clear_rect( - f64::from(entry.x), - f64::from(entry.y), - f64::from(entry.width), - f64::from(entry.height), - ); - ctx.set_text_baseline("top"); - ctx.set_font(&terminal_font_with_px( - &style, - frame.options.font_size_px * dpr, - &frame.options.font_family, - )); - ctx.set_fill_style_str("#ffffff"); - ctx.fill_text(&key.text, f64::from(entry.x), f64::from(entry.y)) - .map_err(|_| "failed to draw glyph into atlas".to_owned())?; - ctx.restore(); - Ok(Some(entry)) -} - -fn render_canvas_backdrop(ctx: &CanvasRenderingContext2d, frame: CanvasSurfaceFrame<'_>) { - let (cell_w, cell_h) = frame.cell_size; - let canvas_width = ctx.canvas().map(|c| c.width() as f64).unwrap_or(0.0); - let canvas_height = ctx.canvas().map(|c| c.height() as f64).unwrap_or(0.0); - ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); - - for (row_idx, row) in frame.visible.iter().enumerate() { - let y = frame.content_top_px + row_idx as f64 * cell_h; - let mut col = 0usize; - for segment in &row.segments { - let x = col as f64 * cell_w; - let width = segment.cols as f64 * cell_w; - if segment.is_cursor { - ctx.set_fill_style_str(&frame.options.theme.cursor_background); - let rect = - cursor_shape_rect(frame.options.cursor.style, x, y, width.max(cell_w), cell_h); - ctx.fill_rect(rect.x, rect.y, rect.width, rect.height); - } else if set_fill_color(ctx, themed_color(&segment.style.background, frame.options)) { - ctx.fill_rect(x, y, width, cell_h); - } - if segment.style.underline { - if segment.is_cursor { - ctx.set_fill_style_str(&frame.options.theme.cursor_foreground); - } else { - set_fill_color(ctx, themed_color(&segment.style.color, frame.options)); - } - ctx.fill_rect(x, y + cell_h - 1.0, width, 1.0); - } - col += segment.cols; - } - } -} - -fn cursor_cell_text(row: &TerminalRow, cursor_col: usize) -> Option { - let mut col = 0usize; - for segment in &row.segments { - let start = col; - let end = col + segment.cols; - if cursor_col >= start && cursor_col < end { - return Some(segment.text.clone()); - } - col = end; - } - None -} - -/// Apply a color string (already resolved to `#rrggbb` or `transparent`) as the -/// Canvas fill style. Returns `true` if a solid color was set; `transparent` -/// is a no-op. -fn set_fill_color(ctx: &CanvasRenderingContext2d, color: &str) -> bool { - let Some(color) = canvas_fill_style(color) else { - return false; - }; - ctx.set_fill_style_str(color); - true -} - -/// Draw the visible terminal rows onto a Canvas 2D context. The context is -/// assumed to be scaled for DPR already; coordinates are in CSS pixels. -fn render_canvas(ctx: &CanvasRenderingContext2d, frame: CanvasSurfaceFrame<'_>) { - let (cell_w, cell_h) = frame.cell_size; - - // Clear the entire canvas. - let canvas_width = ctx.canvas().map(|c| c.width() as f64).unwrap_or(0.0); - let canvas_height = ctx.canvas().map(|c| c.height() as f64).unwrap_or(0.0); - ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); - - ctx.set_text_baseline("top"); - - // First pass: backgrounds. - for (row_idx, row) in frame.visible.iter().enumerate() { - let y = frame.content_top_px + row_idx as f64 * cell_h; - let mut col = 0usize; - for segment in &row.segments { - let x = col as f64 * cell_w; - let width = segment.cols as f64 * cell_w; - if set_fill_color(ctx, themed_color(&segment.style.background, frame.options)) { - ctx.fill_rect(x, y, width, cell_h); - } - col += segment.cols; - } - } - - // Collect cursor cell info for the second pass. - let mut cursor_cell: Option<(f64, f64, String)> = None; - if let Some((cursor_row, cursor_col)) = frame.cursor_pos - && let Some(row) = frame.visible.get(cursor_row) - && let Some(text) = cursor_cell_text(row, cursor_col) - { - cursor_cell = Some(( - cursor_col as f64 * cell_w, - frame.content_top_px + cursor_row as f64 * cell_h, - text, - )); - } - - // Second pass: text and underlines. - for (row_idx, row) in frame.visible.iter().enumerate() { - let y = frame.content_top_px + row_idx as f64 * cell_h; - let mut col = 0usize; - for segment in &row.segments { - let x = col as f64 * cell_w; - let width = segment.cols as f64 * cell_w; - - ctx.set_font(&terminal_font(&segment.style, frame.options)); - set_fill_color(ctx, themed_color(&segment.style.color, frame.options)); - // Baseline offset so text sits in the cell like the DOM line-box. - let _ = ctx.fill_text(&segment.text, x, y); - - if segment.style.underline { - ctx.fill_rect(x, y + cell_h - 1.0, width, 1.0); - } - - col += segment.cols; - } - } - - // Third pass: cursor overlay. - if let Some((cursor_x, cursor_y, text)) = cursor_cell { - ctx.set_fill_style_str(&frame.options.theme.cursor_background); - let rect = cursor_shape_rect( - frame.options.cursor.style, - cursor_x, - cursor_y, - cell_w, - cell_h, - ); - ctx.fill_rect(rect.x, rect.y, rect.width, rect.height); - if frame.options.cursor.style == TerminalCursorStyle::Block { - ctx.set_fill_style_str(&frame.options.theme.cursor_foreground); - ctx.set_font(&terminal_font(&TerminalStyle::default(), frame.options)); - let _ = ctx.fill_text(&text, cursor_x, cursor_y); - } - } -} - /// The outer `.terminal-grid` is sized to the FULL content height /// (`total_px`), constant across re-renders, so swapping the visible window /// never changes the scroll container's geometry — the browser keeps the @@ -3589,8 +2489,7 @@ fn send_input_data( #[cfg(test)] mod tests { use super::*; - use crate::terminal::core::MouseProtocolEncoding; - use crate::terminal::core::TerminalCore; + use crate::terminal::core::{MouseProtocolEncoding, TerminalCore}; use crate::terminal::keyboard::key_to_bytes; fn core_with_lines(n: usize) -> TerminalCore { @@ -3876,31 +2775,6 @@ mod tests { assert_eq!(rects[0].url, "https://example.com"); } - #[test] - fn terminal_font_matches_canvas_style_flags() { - let options = default_options(); - let base = terminal_font(&TerminalStyle::default(), &options); - assert!(base.starts_with("13px ")); - assert!(base.contains("\"JetBrains Mono\"")); - assert!(!base.starts_with("bold")); - assert!(!base.starts_with("italic")); - - let style = TerminalStyle { - bold: true, - italic: true, - ..TerminalStyle::default() - }; - let bold_italic = terminal_font(&style, &options); - assert!(bold_italic.starts_with("bold italic 13px ")); - - let style = TerminalStyle { - italic: true, - ..TerminalStyle::default() - }; - let italic = terminal_font(&style, &options); - assert!(italic.starts_with("italic 13px ")); - } - #[test] fn terminal_options_resolve_safe_defaults_and_css_vars() { let options = TerminalOptions { @@ -3939,42 +2813,6 @@ mod tests { assert!(style.contains("--terminal-cursor-blink:terminal-cursor-blink")); } - #[test] - fn cursor_shape_rect_matches_style() { - let block = cursor_shape_rect(TerminalCursorStyle::Block, 10.0, 20.0, 8.0, 18.0); - assert_eq!( - block, - CursorShapeRect { - x: 10.0, - y: 20.0, - width: 8.0, - height: 18.0 - } - ); - - let underline = cursor_shape_rect(TerminalCursorStyle::Underline, 10.0, 20.0, 8.0, 18.0); - assert_eq!(underline.x, 10.0); - assert_eq!(underline.width, 8.0); - assert_eq!(underline.height, 3.0); - assert_eq!(underline.y, 35.0); - - let bar = cursor_shape_rect(TerminalCursorStyle::Bar, 10.0, 20.0, 8.0, 18.0); - assert_eq!(bar.x, 10.0); - assert_eq!(bar.y, 20.0); - assert_eq!(bar.width, 1.0); - assert_eq!(bar.height, 18.0); - } - - #[test] - fn canvas_fill_style_skips_only_transparent() { - assert_eq!(canvas_fill_style("transparent"), None); - assert_eq!(canvas_fill_style("#dbeafe"), Some("#dbeafe")); - assert_eq!( - canvas_fill_style("rgba(1, 2, 3, 0.5)"), - Some("rgba(1, 2, 3, 0.5)") - ); - } - #[test] fn renderer_canvas_surface_selection() { assert!(renderer_uses_canvas_surface(Renderer::WebGl)); @@ -3983,222 +2821,6 @@ mod tests { assert_eq!(Renderer::default(), Renderer::WebGl); } - #[test] - fn webgl_quad_vertices_cover_full_clip_space() { - let vertices = webgl_quad_vertices(); - assert_eq!(vertices.len(), WEBGL_VERTEX_FLOATS * 6); - assert_eq!(&vertices[0..8], &[-1.0, -1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); - assert_eq!(&vertices[40..48], &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]); - assert_eq!(WEBGL_QUAD_VERTEX_COUNT, 6); - assert_eq!(WEBGL_QUAD_STRIDE_BYTES, 32); - assert_eq!(WEBGL_QUAD_TEXCOORD_OFFSET_BYTES, 8); - assert_eq!(WEBGL_QUAD_COLOR_OFFSET_BYTES, 16); - } - - #[test] - fn webgl_shader_sources_bind_expected_names() { - let (vertex, fragment) = webgl_shader_sources(); - assert!(vertex.contains("a_position")); - assert!(vertex.contains("a_tex_coord")); - assert!(fragment.contains("u_texture")); - assert!(fragment.contains("texture2D")); - assert!(fragment.contains("v_color")); - } - - #[test] - fn webgl_texture_filter_is_crisp_for_terminal_text() { - assert_eq!( - webgl_texture_filter(), - WebGl2RenderingContext::NEAREST as i32 - ); - } - - #[test] - fn split_segment_glyphs_tracks_width_and_color() { - let segment = TerminalSegment { - text: "a界".to_owned(), - cols: 3, - style: TerminalStyle { - color: "#80ff00".to_owned(), - ..TerminalStyle::default() - }, - is_cursor: false, - }; - let options = default_options(); - let cells = split_segment_glyphs(&segment, 2, 4, &options).expect("glyph cells"); - assert_eq!(cells.len(), 2); - assert_eq!(cells[0].key.text, "a"); - assert_eq!(cells[0].key.cols, 1); - assert_eq!(cells[0].col, 4); - assert_eq!(cells[1].key.text, "界"); - assert_eq!(cells[1].key.cols, 2); - assert_eq!(cells[1].col, 5); - assert_eq!(cells[0].fg, [128.0 / 255.0, 1.0, 0.0, 1.0]); - } - - #[test] - fn split_segment_glyphs_only_block_cursor_uses_cursor_foreground() { - let segment = TerminalSegment { - text: "x".to_owned(), - cols: 1, - style: TerminalStyle { - color: "#80ff00".to_owned(), - ..TerminalStyle::default() - }, - is_cursor: true, - }; - - let mut options = default_options(); - options.cursor.style = TerminalCursorStyle::Block; - let block = split_segment_glyphs(&segment, 0, 0, &options).expect("block"); - assert_eq!(block[0].fg, [15.0 / 255.0, 23.0 / 255.0, 42.0 / 255.0, 1.0]); - - options.cursor.style = TerminalCursorStyle::Underline; - let underline = split_segment_glyphs(&segment, 0, 0, &options).expect("underline"); - assert_eq!(underline[0].fg, [128.0 / 255.0, 1.0, 0.0, 1.0]); - } - - #[test] - fn glyph_atlas_reserves_rows_and_reuses_entries() { - let mut atlas = GlyphAtlas::new(32, 1); - let key_a = GlyphKey { - text: "a".to_owned(), - bold: false, - italic: false, - cols: 1, - }; - let first = atlas.reserve(key_a.clone(), 10, 10).expect("first"); - let reused = atlas.reserve(key_a, 10, 10).expect("reused"); - assert_eq!(first, reused); - - let key_b = GlyphKey { - text: "b".to_owned(), - bold: false, - italic: false, - cols: 1, - }; - let second = atlas.reserve(key_b, 20, 10).expect("second"); - assert!(second.y > first.y); - assert!(atlas.dirty); - } - - #[test] - fn push_quad_maps_css_pixels_to_clip_space() { - let dom = CanvasDomState { - backing_width: 200, - backing_height: 100, - css_width: 100, - css_height: 50, - top_px: 0.0, - }; - let mut vertices = Vec::new(); - push_quad( - &mut vertices, - GlyphQuad { - x: 0.0, - y: 0.0, - width: 50.0, - height: 25.0, - uv: [0.0, 1.0, 0.5, 0.5], - color: [1.0, 0.5, 0.0, 1.0], - }, - dom, - ); - assert_eq!(vertices.len(), WEBGL_VERTEX_FLOATS * 6); - assert_eq!(&vertices[0..8], &[-1.0, 0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 1.0]); - } - - #[test] - fn canvas_viewport_top_pins_live_mode_to_bottom() { - let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX; - let vp_rows = 24; - - assert_eq!( - canvas_viewport_top_px(true, 0, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), - total_px - vp_rows as f64 * DEFAULT_LINE_HEIGHT_PX - ); - assert_eq!( - canvas_viewport_top_px(false, 500, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), - 500.0 - ); - assert_eq!( - canvas_viewport_top_px(false, -10, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), - 0.0 - ); - assert_eq!( - canvas_viewport_top_px(true, 0, 10.0, vp_rows, DEFAULT_LINE_HEIGHT_PX), - 0.0 - ); - } - - #[test] - fn canvas_render_plan_tracks_dpr_and_scroll_position() { - let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX; - let offset_px = 980.0 * DEFAULT_LINE_HEIGHT_PX; - let live = canvas_render_plan(CanvasPlanInput { - css_width: 960, - css_height: 360, - dpr: 2.0, - is_live: true, - scroll_top_px: 0, - total_px, - offset_px, - vp_rows: 20, - line_height_px: DEFAULT_LINE_HEIGHT_PX, - }); - - assert_eq!(live.dom_state.backing_width, 1920); - assert_eq!(live.dom_state.backing_height, 720); - assert_eq!(live.dom_state.css_width, 960); - assert_eq!(live.dom_state.css_height, 360); - assert_eq!( - live.dom_state.top_px, - total_px - 20.0 * DEFAULT_LINE_HEIGHT_PX - ); - assert_eq!(live.content_top_px, 0.0); - - let history = canvas_render_plan(CanvasPlanInput { - css_width: 960, - css_height: 360, - dpr: 1.5, - is_live: false, - scroll_top_px: 180, - total_px, - offset_px: 90.0, - vp_rows: 20, - line_height_px: DEFAULT_LINE_HEIGHT_PX, - }); - assert_eq!(history.dom_state.backing_width, 1440); - assert_eq!(history.dom_state.backing_height, 540); - assert_eq!(history.dom_state.top_px, 180.0); - assert_eq!(history.content_top_px, -90.0); - } - - #[test] - fn cursor_cell_text_finds_segment_by_column() { - let row = TerminalRow { - segments: vec![ - TerminalSegment { - text: "ab".to_owned(), - cols: 2, - style: TerminalStyle::default(), - is_cursor: false, - }, - TerminalSegment { - text: "界".to_owned(), - cols: 2, - style: TerminalStyle::default(), - is_cursor: false, - }, - ], - }; - - assert_eq!(cursor_cell_text(&row, 0).as_deref(), Some("ab")); - assert_eq!(cursor_cell_text(&row, 2).as_deref(), Some("界")); - assert_eq!(cursor_cell_text(&row, 3).as_deref(), Some("界")); - assert_eq!(cursor_cell_text(&row, 4), None); - } - #[test] fn viewport_measurement_excludes_terminal_padding() { let options = default_options(); diff --git a/app/src/terminal/mod.rs b/app/src/terminal/mod.rs index 0c8e91a..4238d2e 100644 --- a/app/src/terminal/mod.rs +++ b/app/src/terminal/mod.rs @@ -4,6 +4,7 @@ pub mod ime; pub mod keyboard; pub mod mouse; pub mod protocol; +pub mod renderer; pub mod scrollback; pub mod selection; diff --git a/app/src/terminal/renderer.rs b/app/src/terminal/renderer.rs new file mode 100644 index 0000000..d862853 --- /dev/null +++ b/app/src/terminal/renderer.rs @@ -0,0 +1,1406 @@ +use std::collections::HashMap; +use std::rc::Rc; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; +use wasm_bindgen::JsCast; +use web_sys::js_sys::Float32Array; +use web_sys::{ + CanvasRenderingContext2d, HtmlCanvasElement, WebGl2RenderingContext, WebGlBuffer, WebGlProgram, + WebGlShader, WebGlTexture, WebGlUniformLocation, +}; + +use super::component::{ResolvedTerminalOptions, TerminalCursorStyle}; +use super::core::{TerminalRow, TerminalSegment, TerminalStyle}; + +// --------------------------------------------------------------------------- +// Canvas renderer +// --------------------------------------------------------------------------- + +/// Default terminal foreground / cursor colors, matching `tailwind.css`. +const CANVAS_FG: &str = "#dbeafe"; +const CANVAS_BG: &str = "#0f172a"; + +fn canvas_fill_style(color: &str) -> Option<&str> { + if color == "transparent" { + None + } else { + Some(color) + } +} + +fn canvas_viewport_top_px( + is_live: bool, + scroll_top_px: i32, + total_px: f64, + vp_rows: usize, + line_height_px: f64, +) -> f64 { + if is_live { + (total_px - vp_rows as f64 * line_height_px).max(0.0) + } else { + f64::from(scroll_top_px.max(0)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct CursorShapeRect { + x: f64, + y: f64, + width: f64, + height: f64, +} + +fn cursor_shape_rect( + style: TerminalCursorStyle, + x: f64, + y: f64, + cell_w: f64, + cell_h: f64, +) -> CursorShapeRect { + match style { + TerminalCursorStyle::Block => CursorShapeRect { + x, + y, + width: cell_w, + height: cell_h, + }, + TerminalCursorStyle::Underline => { + let height = (cell_h * 0.15).round().clamp(1.0, 3.0); + CursorShapeRect { + x, + y: y + cell_h - height, + width: cell_w, + height, + } + } + TerminalCursorStyle::Bar => { + let width = (cell_w * 0.18).round().clamp(1.0, 3.0); + CursorShapeRect { + x, + y, + width, + height: cell_h, + } + } + } +} + +fn themed_color<'a>(color: &'a str, options: &'a ResolvedTerminalOptions) -> &'a str { + if color == CANVAS_FG { + &options.theme.foreground + } else if color == CANVAS_BG { + &options.theme.background + } else { + color + } +} + +fn terminal_font(style: &TerminalStyle, options: &ResolvedTerminalOptions) -> String { + terminal_font_with_px(style, options.font_size_px, &options.font_family) +} + +fn terminal_font_with_px(style: &TerminalStyle, px: f64, font_family: &str) -> String { + let mut parts = Vec::with_capacity(3); + if style.bold { + parts.push("bold"); + } + if style.italic { + parts.push("italic"); + } + let px = px.max(1.0); + let font_size = format!("{px}px"); + parts.push(&font_size); + format!("{} {}", parts.join(" "), font_family) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct CanvasDomState { + backing_width: u32, + backing_height: u32, + css_width: i32, + css_height: i32, + top_px: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct CanvasRenderPlan { + dom_state: CanvasDomState, + pub(super) content_top_px: f64, +} + +impl CanvasRenderPlan { + pub(super) fn dom_state(self) -> CanvasDomState { + self.dom_state + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct CanvasPlanInput { + pub(super) css_width: i32, + pub(super) css_height: i32, + pub(super) dpr: f64, + pub(super) is_live: bool, + pub(super) scroll_top_px: i32, + pub(super) total_px: f64, + pub(super) offset_px: f64, + pub(super) vp_rows: usize, + pub(super) line_height_px: f64, +} + +pub(super) fn canvas_render_plan(input: CanvasPlanInput) -> CanvasRenderPlan { + let dpr = input.dpr.max(1.0); + let viewport_top = canvas_viewport_top_px( + input.is_live, + input.scroll_top_px, + input.total_px, + input.vp_rows, + input.line_height_px, + ); + let top_px = if input.is_live { + viewport_top + } else { + f64::from(input.scroll_top_px.max(0)) + }; + + CanvasRenderPlan { + dom_state: CanvasDomState { + backing_width: (f64::from(input.css_width.max(1)) * dpr).ceil() as u32, + backing_height: (f64::from(input.css_height.max(1)) * dpr).ceil() as u32, + css_width: input.css_width.max(1), + css_height: input.css_height.max(1), + top_px, + }, + content_top_px: input.offset_px - viewport_top, + } +} + +fn sync_canvas_dom( + canvas: &HtmlCanvasElement, + next: CanvasDomState, + state: &Rc>>, +) { + let mut current = state.borrow_mut(); + if current.map(|s| s.backing_width) != Some(next.backing_width) { + canvas.set_width(next.backing_width); + } + if current.map(|s| s.backing_height) != Some(next.backing_height) { + canvas.set_height(next.backing_height); + } + if current + .map(|s| { + s.css_width != next.css_width + || s.css_height != next.css_height + || (s.top_px - next.top_px).abs() > 0.5 + }) + .unwrap_or(true) + { + let _ = canvas.set_attribute( + "style", + &format!( + "width:{}px;height:{}px;top:{}px;", + next.css_width, next.css_height, next.top_px + ), + ); + } + *current = Some(next); +} + +fn sync_canvas_bitmap(canvas: &HtmlCanvasElement, next: CanvasDomState) { + if canvas.width() != next.backing_width { + canvas.set_width(next.backing_width); + } + if canvas.height() != next.backing_height { + canvas.set_height(next.backing_height); + } +} + +fn canvas_2d_context(canvas: &HtmlCanvasElement) -> Option { + canvas + .get_context("2d") + .ok() + .flatten()? + .dyn_into::() + .ok() +} + +#[derive(Clone, Copy)] +pub(super) struct CanvasSurfaceFrame<'a> { + pub(super) dom_state: CanvasDomState, + pub(super) dpr: f64, + pub(super) visible: &'a [TerminalRow], + pub(super) cell_size: (f64, f64), + pub(super) content_top_px: f64, + pub(super) cursor_pos: Option<(usize, usize)>, + pub(super) options: &'a ResolvedTerminalOptions, +} + +pub(super) struct RenderCanvasSurfaceRequest<'a> { + pub(super) canvas_el: &'a HtmlCanvasElement, + pub(super) frame: CanvasSurfaceFrame<'a>, + pub(super) canvas_state: &'a Rc>>, +} + +pub(super) fn render_canvas_surface(request: RenderCanvasSurfaceRequest<'_>) { + sync_canvas_dom( + request.canvas_el, + request.frame.dom_state, + request.canvas_state, + ); + let Some(ctx) = canvas_2d_context(request.canvas_el) else { + return; + }; + let _ = ctx.set_transform(request.frame.dpr, 0.0, 0.0, request.frame.dpr, 0.0, 0.0); + render_canvas(&ctx, request.frame); +} + +const WEBGL_VERTEX_SHADER: &str = r#" +attribute vec2 a_position; +attribute vec2 a_tex_coord; +attribute vec4 a_color; +varying vec2 v_tex_coord; +varying vec4 v_color; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); + v_tex_coord = a_tex_coord; + v_color = a_color; +} +"#; + +const WEBGL_FRAGMENT_SHADER: &str = r#" +precision mediump float; +uniform sampler2D u_texture; +varying vec2 v_tex_coord; +varying vec4 v_color; + +void main() { + gl_FragColor = texture2D(u_texture, v_tex_coord) * v_color; +} +"#; + +const WEBGL_QUAD_VERTEX_COUNT: i32 = 6; +const WEBGL_VERTEX_FLOATS: usize = 8; +const WEBGL_QUAD_STRIDE_BYTES: i32 = (WEBGL_VERTEX_FLOATS * 4) as i32; +const WEBGL_QUAD_TEXCOORD_OFFSET_BYTES: i32 = 2 * 4; +const WEBGL_QUAD_COLOR_OFFSET_BYTES: i32 = 4 * 4; +const GLYPH_ATLAS_SIZE: u32 = 2048; +const GLYPH_ATLAS_PADDING: u32 = 2; + +fn webgl_quad_vertices() -> [f32; WEBGL_VERTEX_FLOATS * 6] { + [ + -1.0, -1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, // + 1.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, // + -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, // + -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, // + 1.0, -1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0, // + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + ] +} + +fn webgl_shader_sources() -> (&'static str, &'static str) { + (WEBGL_VERTEX_SHADER, WEBGL_FRAGMENT_SHADER) +} + +fn webgl_texture_filter() -> i32 { + WebGl2RenderingContext::NEAREST as i32 +} + +pub(super) struct WebGlRendererState { + source_canvas: HtmlCanvasElement, + source_ctx: CanvasRenderingContext2d, + atlas_canvas: HtmlCanvasElement, + atlas_ctx: CanvasRenderingContext2d, + atlas: GlyphAtlas, + gl: WebGl2RenderingContext, + program: WebGlProgram, + atlas_texture: WebGlTexture, + fallback_texture: WebGlTexture, + buffer: WebGlBuffer, + position_location: u32, + tex_coord_location: u32, + color_location: u32, + sampler_location: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct GlyphKey { + text: String, + bold: bool, + italic: bool, + cols: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct GlyphAtlasEntry { + x: u32, + y: u32, + width: u32, + height: u32, + cols: usize, +} + +#[derive(Debug)] +struct GlyphAtlas { + size: u32, + padding: u32, + next_x: u32, + next_y: u32, + row_height: u32, + entries: HashMap, + dirty: bool, +} + +impl GlyphAtlas { + fn new(size: u32, padding: u32) -> Self { + Self { + size, + padding, + next_x: padding, + next_y: padding, + row_height: 0, + entries: HashMap::new(), + dirty: true, + } + } + + fn reset(&mut self) { + self.next_x = self.padding; + self.next_y = self.padding; + self.row_height = 0; + self.entries.clear(); + self.dirty = true; + } + + fn get(&self, key: &GlyphKey) -> Option { + self.entries.get(key).copied() + } + + fn reserve(&mut self, key: GlyphKey, width: u32, height: u32) -> Option { + if let Some(entry) = self.get(&key) { + return Some(entry); + } + let width = width.max(1); + let height = height.max(1); + let padded_width = width + self.padding * 2; + let padded_height = height + self.padding * 2; + if padded_width > self.size || padded_height > self.size { + return None; + } + if self.next_x + padded_width > self.size { + self.next_x = self.padding; + self.next_y += self.row_height; + self.row_height = 0; + } + if self.next_y + padded_height > self.size { + return None; + } + + let entry = GlyphAtlasEntry { + x: self.next_x + self.padding, + y: self.next_y + self.padding, + width, + height, + cols: key.cols, + }; + self.next_x += padded_width; + self.row_height = self.row_height.max(padded_height); + self.entries.insert(key, entry); + self.dirty = true; + Some(entry) + } +} + +#[derive(Clone, Debug, PartialEq)] +struct GlyphCell { + key: GlyphKey, + row: usize, + col: usize, + fg: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct GlyphQuad { + x: f64, + y: f64, + width: f64, + height: f64, + uv: [f64; 4], + color: [f32; 4], +} + +fn split_segment_glyphs( + segment: &TerminalSegment, + row: usize, + start_col: usize, + options: &ResolvedTerminalOptions, +) -> Option> { + let mut cells = Vec::new(); + let mut col = start_col; + let mut used_cols = 0usize; + for grapheme in UnicodeSegmentation::graphemes(segment.text.as_str(), true) { + let mut cols = UnicodeWidthStr::width(grapheme); + if cols == 0 { + cols = 1; + } + if used_cols + cols > segment.cols { + return None; + } + let fg = if segment.is_cursor && options.cursor.style == TerminalCursorStyle::Block { + parse_css_rgb(&options.theme.cursor_foreground)? + } else { + parse_css_rgb(themed_color(&segment.style.color, options))? + }; + cells.push(GlyphCell { + key: GlyphKey { + text: grapheme.to_owned(), + bold: segment.style.bold, + italic: segment.style.italic, + cols, + }, + row, + col, + fg, + }); + col += cols; + used_cols += cols; + } + if used_cols == segment.cols { + Some(cells) + } else { + None + } +} + +fn collect_glyph_cells( + visible: &[TerminalRow], + options: &ResolvedTerminalOptions, +) -> Option> { + let mut cells = Vec::new(); + for (row_idx, row) in visible.iter().enumerate() { + let mut col = 0usize; + for segment in &row.segments { + cells.extend(split_segment_glyphs(segment, row_idx, col, options)?); + col += segment.cols; + } + } + Some(cells) +} + +fn parse_css_rgb(color: &str) -> Option<[f32; 4]> { + let color = color.trim(); + if color == "transparent" { + return Some([0.0, 0.0, 0.0, 0.0]); + } + if let Some(hex) = color.strip_prefix('#') + && hex.len() == 6 + { + let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0; + let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0; + let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0; + return Some([r, g, b, 1.0]); + } + if let Some(parts) = color + .strip_prefix("rgb(") + .and_then(|s| s.strip_suffix(')')) + .map(|s| s.split(',').collect::>()) + && parts.len() == 3 + { + let r = parts[0].trim().parse::().ok()? / 255.0; + let g = parts[1].trim().parse::().ok()? / 255.0; + let b = parts[2].trim().parse::().ok()? / 255.0; + return Some([r, g, b, 1.0]); + } + None +} + +fn push_quad(vertices: &mut Vec, quad: GlyphQuad, dom_state: CanvasDomState) { + let w = dom_state.css_width as f64; + let h = dom_state.css_height as f64; + let x1 = (quad.x / w * 2.0 - 1.0) as f32; + let x2 = ((quad.x + quad.width) / w * 2.0 - 1.0) as f32; + let y1 = (1.0 - quad.y / h * 2.0) as f32; + let y2 = (1.0 - (quad.y + quad.height) / h * 2.0) as f32; + let [u1, v1, u2, v2] = quad.uv.map(|v| v as f32); + let [r, g, b, a] = quad.color; + let data = [ + x1, y2, u1, v2, r, g, b, a, // + x2, y2, u2, v2, r, g, b, a, // + x1, y1, u1, v1, r, g, b, a, // + x1, y1, u1, v1, r, g, b, a, // + x2, y2, u2, v2, r, g, b, a, // + x2, y1, u2, v1, r, g, b, a, + ]; + vertices.extend_from_slice(&data); +} + +fn glyph_uv(entry: GlyphAtlasEntry, atlas_size: u32) -> [f64; 4] { + let size = f64::from(atlas_size); + [ + f64::from(entry.x) / size, + 1.0 - f64::from(entry.y) / size, + f64::from(entry.x + entry.width) / size, + 1.0 - f64::from(entry.y + entry.height) / size, + ] +} + +pub(super) fn render_webgl( + canvas: &HtmlCanvasElement, + frame: CanvasSurfaceFrame<'_>, + canvas_state: &Rc>>, + webgl_state: &Rc>>, +) -> Result<(), String> { + sync_canvas_dom(canvas, frame.dom_state, canvas_state); + let mut state_ref = webgl_state.borrow_mut(); + if state_ref.is_none() { + *state_ref = Some(create_webgl_renderer(canvas)?); + } + let state = state_ref + .as_mut() + .ok_or_else(|| "missing WebGL renderer state".to_owned())?; + + sync_canvas_bitmap(&state.source_canvas, frame.dom_state); + let _ = state + .source_ctx + .set_transform(frame.dpr, 0.0, 0.0, frame.dpr, 0.0, 0.0); + render_canvas_backdrop(&state.source_ctx, frame); + let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else { + render_canvas(&state.source_ctx, frame); + draw_webgl_texture_frame(state, frame.dom_state)?; + return Ok(()); + }; + + draw_webgl_texture_frame(state, frame.dom_state)?; + if draw_webgl_glyphs(state, frame, &cells).is_err() { + render_canvas(&state.source_ctx, frame); + draw_webgl_texture_frame(state, frame.dom_state)?; + } + Ok(()) +} + +fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result { + let gl = canvas + .get_context("webgl2") + .map_err(|_| "failed to request WebGL2 context".to_owned())? + .ok_or_else(|| "WebGL2 context is unavailable".to_owned())? + .dyn_into::() + .map_err(|_| "context is not WebGL2".to_owned())?; + + let (vertex_source, fragment_source) = webgl_shader_sources(); + let vertex_shader = + compile_webgl_shader(&gl, WebGl2RenderingContext::VERTEX_SHADER, vertex_source)?; + let fragment_shader = compile_webgl_shader( + &gl, + WebGl2RenderingContext::FRAGMENT_SHADER, + fragment_source, + )?; + let program = link_webgl_program(&gl, &vertex_shader, &fragment_shader)?; + + let position_location = gl.get_attrib_location(&program, "a_position"); + let tex_coord_location = gl.get_attrib_location(&program, "a_tex_coord"); + let color_location = gl.get_attrib_location(&program, "a_color"); + if position_location < 0 || tex_coord_location < 0 || color_location < 0 { + return Err("WebGL shader attribute locations are missing".to_owned()); + } + let sampler_location = gl.get_uniform_location(&program, "u_texture"); + + let buffer = gl + .create_buffer() + .ok_or_else(|| "failed to create WebGL vertex buffer".to_owned())?; + gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&buffer)); + let vertices = webgl_quad_vertices(); + unsafe { + let vertex_array = Float32Array::view(&vertices); + gl.buffer_data_with_array_buffer_view( + WebGl2RenderingContext::ARRAY_BUFFER, + vertex_array.as_ref(), + WebGl2RenderingContext::STATIC_DRAW, + ); + } + + let atlas_texture = gl + .create_texture() + .ok_or_else(|| "failed to create WebGL atlas texture".to_owned())?; + configure_webgl_texture(&gl, &atlas_texture); + let fallback_texture = gl + .create_texture() + .ok_or_else(|| "failed to create WebGL fallback texture".to_owned())?; + configure_webgl_texture(&gl, &fallback_texture); + gl.pixel_storei(WebGl2RenderingContext::UNPACK_FLIP_Y_WEBGL, 1); + gl.pixel_storei(WebGl2RenderingContext::UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1); + gl.enable(WebGl2RenderingContext::BLEND); + gl.blend_func( + WebGl2RenderingContext::ONE, + WebGl2RenderingContext::ONE_MINUS_SRC_ALPHA, + ); + gl.disable(WebGl2RenderingContext::DEPTH_TEST); + gl.disable(WebGl2RenderingContext::CULL_FACE); + + let document = web_sys::window() + .and_then(|window| window.document()) + .ok_or_else(|| "document is unavailable".to_owned())?; + let source_canvas = document + .create_element("canvas") + .map_err(|_| "failed to create source canvas element".to_owned())? + .dyn_into::() + .map_err(|_| "source element is not a canvas".to_owned())?; + let source_ctx = canvas_2d_context(&source_canvas) + .ok_or_else(|| "failed to create source Canvas 2D context".to_owned())?; + let atlas_canvas = document + .create_element("canvas") + .map_err(|_| "failed to create atlas canvas element".to_owned())? + .dyn_into::() + .map_err(|_| "atlas element is not a canvas".to_owned())?; + atlas_canvas.set_width(GLYPH_ATLAS_SIZE); + atlas_canvas.set_height(GLYPH_ATLAS_SIZE); + let atlas_ctx = canvas_2d_context(&atlas_canvas) + .ok_or_else(|| "failed to create atlas Canvas 2D context".to_owned())?; + atlas_ctx.clear_rect( + 0.0, + 0.0, + f64::from(GLYPH_ATLAS_SIZE), + f64::from(GLYPH_ATLAS_SIZE), + ); + + Ok(WebGlRendererState { + source_canvas, + source_ctx, + atlas_canvas, + atlas_ctx, + atlas: GlyphAtlas::new(GLYPH_ATLAS_SIZE, GLYPH_ATLAS_PADDING), + gl, + program, + atlas_texture, + fallback_texture, + buffer, + position_location: position_location as u32, + tex_coord_location: tex_coord_location as u32, + color_location: color_location as u32, + sampler_location, + }) +} + +fn configure_webgl_texture(gl: &WebGl2RenderingContext, texture: &WebGlTexture) { + gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(texture)); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MIN_FILTER, + webgl_texture_filter(), + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_MAG_FILTER, + webgl_texture_filter(), + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_S, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); + gl.tex_parameteri( + WebGl2RenderingContext::TEXTURE_2D, + WebGl2RenderingContext::TEXTURE_WRAP_T, + WebGl2RenderingContext::CLAMP_TO_EDGE as i32, + ); +} + +fn compile_webgl_shader( + gl: &WebGl2RenderingContext, + shader_type: u32, + source: &str, +) -> Result { + let shader = gl + .create_shader(shader_type) + .ok_or_else(|| "failed to create WebGL shader".to_owned())?; + gl.shader_source(&shader, source); + gl.compile_shader(&shader); + if gl + .get_shader_parameter(&shader, WebGl2RenderingContext::COMPILE_STATUS) + .as_bool() + .unwrap_or(false) + { + Ok(shader) + } else { + Err(gl + .get_shader_info_log(&shader) + .unwrap_or_else(|| "WebGL shader compile failed".to_owned())) + } +} + +fn link_webgl_program( + gl: &WebGl2RenderingContext, + vertex_shader: &WebGlShader, + fragment_shader: &WebGlShader, +) -> Result { + let program = gl + .create_program() + .ok_or_else(|| "failed to create WebGL program".to_owned())?; + gl.attach_shader(&program, vertex_shader); + gl.attach_shader(&program, fragment_shader); + gl.link_program(&program); + if gl + .get_program_parameter(&program, WebGl2RenderingContext::LINK_STATUS) + .as_bool() + .unwrap_or(false) + { + Ok(program) + } else { + Err(gl + .get_program_info_log(&program) + .unwrap_or_else(|| "WebGL program link failed".to_owned())) + } +} + +fn bind_webgl_vertex_layout(gl: &WebGl2RenderingContext, state: &WebGlRendererState) { + gl.bind_buffer(WebGl2RenderingContext::ARRAY_BUFFER, Some(&state.buffer)); + gl.enable_vertex_attrib_array(state.position_location); + gl.vertex_attrib_pointer_with_i32( + state.position_location, + 2, + WebGl2RenderingContext::FLOAT, + false, + WEBGL_QUAD_STRIDE_BYTES, + 0, + ); + gl.enable_vertex_attrib_array(state.tex_coord_location); + gl.vertex_attrib_pointer_with_i32( + state.tex_coord_location, + 2, + WebGl2RenderingContext::FLOAT, + false, + WEBGL_QUAD_STRIDE_BYTES, + WEBGL_QUAD_TEXCOORD_OFFSET_BYTES, + ); + gl.enable_vertex_attrib_array(state.color_location); + gl.vertex_attrib_pointer_with_i32( + state.color_location, + 4, + WebGl2RenderingContext::FLOAT, + false, + WEBGL_QUAD_STRIDE_BYTES, + WEBGL_QUAD_COLOR_OFFSET_BYTES, + ); +} + +fn upload_webgl_vertices(gl: &WebGl2RenderingContext, vertices: &[f32]) { + unsafe { + let vertex_array = Float32Array::view(vertices); + gl.buffer_data_with_array_buffer_view( + WebGl2RenderingContext::ARRAY_BUFFER, + vertex_array.as_ref(), + WebGl2RenderingContext::DYNAMIC_DRAW, + ); + } +} + +fn draw_webgl_texture_frame( + state: &WebGlRendererState, + dom_state: CanvasDomState, +) -> Result<(), String> { + let gl = &state.gl; + gl.viewport( + 0, + 0, + dom_state.backing_width as i32, + dom_state.backing_height as i32, + ); + gl.clear_color(0.0, 0.0, 0.0, 0.0); + gl.clear(WebGl2RenderingContext::COLOR_BUFFER_BIT); + gl.use_program(Some(&state.program)); + + bind_webgl_vertex_layout(gl, state); + upload_webgl_vertices(gl, &webgl_quad_vertices()); + + gl.active_texture(WebGl2RenderingContext::TEXTURE0); + gl.bind_texture( + WebGl2RenderingContext::TEXTURE_2D, + Some(&state.fallback_texture), + ); + gl.uniform1i(state.sampler_location.as_ref(), 0); + gl.tex_image_2d_with_u32_and_u32_and_html_canvas_element( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + &state.source_canvas, + ) + .map_err(|_| "failed to upload source canvas to WebGL fallback texture".to_owned())?; + gl.draw_arrays( + WebGl2RenderingContext::TRIANGLES, + 0, + WEBGL_QUAD_VERTEX_COUNT, + ); + Ok(()) +} + +fn draw_webgl_glyphs( + state: &mut WebGlRendererState, + frame: CanvasSurfaceFrame<'_>, + cells: &[GlyphCell], +) -> Result<(), String> { + ensure_glyphs_in_atlas(state, frame, cells)?; + if state.atlas.dirty { + state.gl.bind_texture( + WebGl2RenderingContext::TEXTURE_2D, + Some(&state.atlas_texture), + ); + state + .gl + .tex_image_2d_with_u32_and_u32_and_html_canvas_element( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + &state.atlas_canvas, + ) + .map_err(|_| "failed to upload glyph atlas texture".to_owned())?; + state.atlas.dirty = false; + } + + let mut vertices = Vec::with_capacity(cells.len() * WEBGL_VERTEX_FLOATS * 6); + for cell in cells { + let Some(entry) = state.atlas.get(&cell.key) else { + continue; + }; + let x = cell.col as f64 * frame.cell_size.0; + let y = frame.content_top_px + cell.row as f64 * frame.cell_size.1; + push_quad( + &mut vertices, + GlyphQuad { + x, + y, + width: entry.cols as f64 * frame.cell_size.0, + height: frame.cell_size.1, + uv: glyph_uv(entry, state.atlas.size), + color: cell.fg, + }, + frame.dom_state, + ); + } + if vertices.is_empty() { + return Ok(()); + } + + let gl = &state.gl; + gl.use_program(Some(&state.program)); + bind_webgl_vertex_layout(gl, state); + upload_webgl_vertices(gl, &vertices); + gl.active_texture(WebGl2RenderingContext::TEXTURE0); + gl.bind_texture( + WebGl2RenderingContext::TEXTURE_2D, + Some(&state.atlas_texture), + ); + gl.uniform1i(state.sampler_location.as_ref(), 0); + gl.draw_arrays( + WebGl2RenderingContext::TRIANGLES, + 0, + (vertices.len() / WEBGL_VERTEX_FLOATS) as i32, + ); + Ok(()) +} + +fn ensure_glyphs_in_atlas( + state: &mut WebGlRendererState, + frame: CanvasSurfaceFrame<'_>, + cells: &[GlyphCell], +) -> Result<(), String> { + for attempt in 0..2 { + if attempt == 1 { + state.atlas.reset(); + state.atlas_ctx.clear_rect( + 0.0, + 0.0, + f64::from(state.atlas.size), + f64::from(state.atlas.size), + ); + } + let mut ok = true; + for cell in cells { + if state.atlas.get(&cell.key).is_none() + && draw_glyph_into_atlas(state, frame, &cell.key)?.is_none() + { + ok = false; + break; + } + } + if ok { + return Ok(()); + } + } + Err("glyph atlas is full".to_owned()) +} + +fn draw_glyph_into_atlas( + state: &mut WebGlRendererState, + frame: CanvasSurfaceFrame<'_>, + key: &GlyphKey, +) -> Result, String> { + let dpr = frame.dpr.max(1.0); + let width = (key.cols as f64 * frame.cell_size.0 * dpr).ceil() as u32; + let height = (frame.cell_size.1 * dpr).ceil() as u32; + let Some(entry) = state.atlas.reserve(key.clone(), width, height) else { + return Ok(None); + }; + let style = TerminalStyle { + color: "#ffffff".to_owned(), + background: "transparent".to_owned(), + bold: key.bold, + italic: key.italic, + underline: false, + }; + let ctx = &state.atlas_ctx; + ctx.save(); + ctx.begin_path(); + ctx.rect( + f64::from(entry.x), + f64::from(entry.y), + f64::from(entry.width), + f64::from(entry.height), + ); + ctx.clip(); + ctx.set_fill_style_str("rgba(0,0,0,0)"); + ctx.clear_rect( + f64::from(entry.x), + f64::from(entry.y), + f64::from(entry.width), + f64::from(entry.height), + ); + ctx.set_text_baseline("top"); + ctx.set_font(&terminal_font_with_px( + &style, + frame.options.font_size_px * dpr, + &frame.options.font_family, + )); + ctx.set_fill_style_str("#ffffff"); + ctx.fill_text(&key.text, f64::from(entry.x), f64::from(entry.y)) + .map_err(|_| "failed to draw glyph into atlas".to_owned())?; + ctx.restore(); + Ok(Some(entry)) +} + +fn render_canvas_backdrop(ctx: &CanvasRenderingContext2d, frame: CanvasSurfaceFrame<'_>) { + let (cell_w, cell_h) = frame.cell_size; + let canvas_width = ctx.canvas().map(|c| c.width() as f64).unwrap_or(0.0); + let canvas_height = ctx.canvas().map(|c| c.height() as f64).unwrap_or(0.0); + ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); + + for (row_idx, row) in frame.visible.iter().enumerate() { + let y = frame.content_top_px + row_idx as f64 * cell_h; + let mut col = 0usize; + for segment in &row.segments { + let x = col as f64 * cell_w; + let width = segment.cols as f64 * cell_w; + if segment.is_cursor { + ctx.set_fill_style_str(&frame.options.theme.cursor_background); + let rect = + cursor_shape_rect(frame.options.cursor.style, x, y, width.max(cell_w), cell_h); + ctx.fill_rect(rect.x, rect.y, rect.width, rect.height); + } else if set_fill_color(ctx, themed_color(&segment.style.background, frame.options)) { + ctx.fill_rect(x, y, width, cell_h); + } + if segment.style.underline { + if segment.is_cursor { + ctx.set_fill_style_str(&frame.options.theme.cursor_foreground); + } else { + set_fill_color(ctx, themed_color(&segment.style.color, frame.options)); + } + ctx.fill_rect(x, y + cell_h - 1.0, width, 1.0); + } + col += segment.cols; + } + } +} + +fn cursor_cell_text(row: &TerminalRow, cursor_col: usize) -> Option { + let mut col = 0usize; + for segment in &row.segments { + let start = col; + let end = col + segment.cols; + if cursor_col >= start && cursor_col < end { + return Some(segment.text.clone()); + } + col = end; + } + None +} + +/// Apply a color string (already resolved to `#rrggbb` or `transparent`) as the +/// Canvas fill style. Returns `true` if a solid color was set; `transparent` +/// is a no-op. +fn set_fill_color(ctx: &CanvasRenderingContext2d, color: &str) -> bool { + let Some(color) = canvas_fill_style(color) else { + return false; + }; + ctx.set_fill_style_str(color); + true +} + +/// Draw the visible terminal rows onto a Canvas 2D context. The context is +/// assumed to be scaled for DPR already; coordinates are in CSS pixels. +fn render_canvas(ctx: &CanvasRenderingContext2d, frame: CanvasSurfaceFrame<'_>) { + let (cell_w, cell_h) = frame.cell_size; + + // Clear the entire canvas. + let canvas_width = ctx.canvas().map(|c| c.width() as f64).unwrap_or(0.0); + let canvas_height = ctx.canvas().map(|c| c.height() as f64).unwrap_or(0.0); + ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height); + + ctx.set_text_baseline("top"); + + // First pass: backgrounds. + for (row_idx, row) in frame.visible.iter().enumerate() { + let y = frame.content_top_px + row_idx as f64 * cell_h; + let mut col = 0usize; + for segment in &row.segments { + let x = col as f64 * cell_w; + let width = segment.cols as f64 * cell_w; + if set_fill_color(ctx, themed_color(&segment.style.background, frame.options)) { + ctx.fill_rect(x, y, width, cell_h); + } + col += segment.cols; + } + } + + // Collect cursor cell info for the second pass. + let mut cursor_cell: Option<(f64, f64, String)> = None; + if let Some((cursor_row, cursor_col)) = frame.cursor_pos + && let Some(row) = frame.visible.get(cursor_row) + && let Some(text) = cursor_cell_text(row, cursor_col) + { + cursor_cell = Some(( + cursor_col as f64 * cell_w, + frame.content_top_px + cursor_row as f64 * cell_h, + text, + )); + } + + // Second pass: text and underlines. + for (row_idx, row) in frame.visible.iter().enumerate() { + let y = frame.content_top_px + row_idx as f64 * cell_h; + let mut col = 0usize; + for segment in &row.segments { + let x = col as f64 * cell_w; + let width = segment.cols as f64 * cell_w; + + ctx.set_font(&terminal_font(&segment.style, frame.options)); + set_fill_color(ctx, themed_color(&segment.style.color, frame.options)); + // Baseline offset so text sits in the cell like the DOM line-box. + let _ = ctx.fill_text(&segment.text, x, y); + + if segment.style.underline { + ctx.fill_rect(x, y + cell_h - 1.0, width, 1.0); + } + + col += segment.cols; + } + } + + // Third pass: cursor overlay. + if let Some((cursor_x, cursor_y, text)) = cursor_cell { + ctx.set_fill_style_str(&frame.options.theme.cursor_background); + let rect = cursor_shape_rect( + frame.options.cursor.style, + cursor_x, + cursor_y, + cell_w, + cell_h, + ); + ctx.fill_rect(rect.x, rect.y, rect.width, rect.height); + if frame.options.cursor.style == TerminalCursorStyle::Block { + ctx.set_fill_style_str(&frame.options.theme.cursor_foreground); + ctx.set_font(&terminal_font(&TerminalStyle::default(), frame.options)); + let _ = ctx.fill_text(&text, cursor_x, cursor_y); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::component::TerminalOptions; + use super::*; + + const DEFAULT_LINE_HEIGHT_PX: f64 = 18.0; + + fn default_options() -> ResolvedTerminalOptions { + ResolvedTerminalOptions::from(TerminalOptions::default()) + } + + #[test] + fn terminal_font_matches_canvas_style_flags() { + let options = default_options(); + let base = terminal_font(&TerminalStyle::default(), &options); + assert!(base.starts_with("13px ")); + assert!(base.contains("\"JetBrains Mono\"")); + assert!(!base.starts_with("bold")); + assert!(!base.starts_with("italic")); + + let style = TerminalStyle { + bold: true, + italic: true, + ..TerminalStyle::default() + }; + let bold_italic = terminal_font(&style, &options); + assert!(bold_italic.starts_with("bold italic 13px ")); + + let style = TerminalStyle { + italic: true, + ..TerminalStyle::default() + }; + let italic = terminal_font(&style, &options); + assert!(italic.starts_with("italic 13px ")); + } + + #[test] + fn cursor_shape_rect_matches_style() { + let block = cursor_shape_rect(TerminalCursorStyle::Block, 10.0, 20.0, 8.0, 18.0); + assert_eq!( + block, + CursorShapeRect { + x: 10.0, + y: 20.0, + width: 8.0, + height: 18.0 + } + ); + + let underline = cursor_shape_rect(TerminalCursorStyle::Underline, 10.0, 20.0, 8.0, 18.0); + assert_eq!(underline.x, 10.0); + assert_eq!(underline.width, 8.0); + assert_eq!(underline.height, 3.0); + assert_eq!(underline.y, 35.0); + + let bar = cursor_shape_rect(TerminalCursorStyle::Bar, 10.0, 20.0, 8.0, 18.0); + assert_eq!(bar.x, 10.0); + assert_eq!(bar.y, 20.0); + assert_eq!(bar.width, 1.0); + assert_eq!(bar.height, 18.0); + } + + #[test] + fn canvas_fill_style_skips_only_transparent() { + assert_eq!(canvas_fill_style("transparent"), None); + assert_eq!(canvas_fill_style("#dbeafe"), Some("#dbeafe")); + assert_eq!( + canvas_fill_style("rgba(1, 2, 3, 0.5)"), + Some("rgba(1, 2, 3, 0.5)") + ); + } + + #[test] + fn webgl_quad_vertices_cover_full_clip_space() { + let vertices = webgl_quad_vertices(); + assert_eq!(vertices.len(), WEBGL_VERTEX_FLOATS * 6); + assert_eq!(&vertices[0..8], &[-1.0, -1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); + assert_eq!(&vertices[40..48], &[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]); + assert_eq!(WEBGL_QUAD_VERTEX_COUNT, 6); + assert_eq!(WEBGL_QUAD_STRIDE_BYTES, 32); + assert_eq!(WEBGL_QUAD_TEXCOORD_OFFSET_BYTES, 8); + assert_eq!(WEBGL_QUAD_COLOR_OFFSET_BYTES, 16); + } + + #[test] + fn webgl_shader_sources_bind_expected_names() { + let (vertex, fragment) = webgl_shader_sources(); + assert!(vertex.contains("a_position")); + assert!(vertex.contains("a_tex_coord")); + assert!(fragment.contains("u_texture")); + assert!(fragment.contains("texture2D")); + assert!(fragment.contains("v_color")); + } + + #[test] + fn webgl_texture_filter_is_crisp_for_terminal_text() { + assert_eq!( + webgl_texture_filter(), + WebGl2RenderingContext::NEAREST as i32 + ); + } + + #[test] + fn split_segment_glyphs_tracks_width_and_color() { + let segment = TerminalSegment { + text: "a界".to_owned(), + cols: 3, + style: TerminalStyle { + color: "#80ff00".to_owned(), + ..TerminalStyle::default() + }, + is_cursor: false, + }; + let options = default_options(); + let cells = split_segment_glyphs(&segment, 2, 4, &options).expect("glyph cells"); + assert_eq!(cells.len(), 2); + assert_eq!(cells[0].key.text, "a"); + assert_eq!(cells[0].key.cols, 1); + assert_eq!(cells[0].col, 4); + assert_eq!(cells[1].key.text, "界"); + assert_eq!(cells[1].key.cols, 2); + assert_eq!(cells[1].col, 5); + assert_eq!(cells[0].fg, [128.0 / 255.0, 1.0, 0.0, 1.0]); + } + + #[test] + fn split_segment_glyphs_only_block_cursor_uses_cursor_foreground() { + let segment = TerminalSegment { + text: "x".to_owned(), + cols: 1, + style: TerminalStyle { + color: "#80ff00".to_owned(), + ..TerminalStyle::default() + }, + is_cursor: true, + }; + + let mut options = default_options(); + options.cursor.style = TerminalCursorStyle::Block; + let block = split_segment_glyphs(&segment, 0, 0, &options).expect("block"); + assert_eq!(block[0].fg, [15.0 / 255.0, 23.0 / 255.0, 42.0 / 255.0, 1.0]); + + options.cursor.style = TerminalCursorStyle::Underline; + let underline = split_segment_glyphs(&segment, 0, 0, &options).expect("underline"); + assert_eq!(underline[0].fg, [128.0 / 255.0, 1.0, 0.0, 1.0]); + } + + #[test] + fn glyph_atlas_reserves_rows_and_reuses_entries() { + let mut atlas = GlyphAtlas::new(32, 1); + let key_a = GlyphKey { + text: "a".to_owned(), + bold: false, + italic: false, + cols: 1, + }; + let first = atlas.reserve(key_a.clone(), 10, 10).expect("first"); + let reused = atlas.reserve(key_a, 10, 10).expect("reused"); + assert_eq!(first, reused); + + let key_b = GlyphKey { + text: "b".to_owned(), + bold: false, + italic: false, + cols: 1, + }; + let second = atlas.reserve(key_b, 20, 10).expect("second"); + assert!(second.y > first.y); + assert!(atlas.dirty); + } + + #[test] + fn push_quad_maps_css_pixels_to_clip_space() { + let dom = CanvasDomState { + backing_width: 200, + backing_height: 100, + css_width: 100, + css_height: 50, + top_px: 0.0, + }; + let mut vertices = Vec::new(); + push_quad( + &mut vertices, + GlyphQuad { + x: 0.0, + y: 0.0, + width: 50.0, + height: 25.0, + uv: [0.0, 1.0, 0.5, 0.5], + color: [1.0, 0.5, 0.0, 1.0], + }, + dom, + ); + assert_eq!(vertices.len(), WEBGL_VERTEX_FLOATS * 6); + assert_eq!(&vertices[0..8], &[-1.0, 0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 1.0]); + } + + #[test] + fn canvas_viewport_top_pins_live_mode_to_bottom() { + let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX; + let vp_rows = 24; + + assert_eq!( + canvas_viewport_top_px(true, 0, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), + total_px - vp_rows as f64 * DEFAULT_LINE_HEIGHT_PX + ); + assert_eq!( + canvas_viewport_top_px(false, 500, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), + 500.0 + ); + assert_eq!( + canvas_viewport_top_px(false, -10, total_px, vp_rows, DEFAULT_LINE_HEIGHT_PX), + 0.0 + ); + assert_eq!( + canvas_viewport_top_px(true, 0, 10.0, vp_rows, DEFAULT_LINE_HEIGHT_PX), + 0.0 + ); + } + + #[test] + fn canvas_render_plan_tracks_dpr_and_scroll_position() { + let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX; + let offset_px = 980.0 * DEFAULT_LINE_HEIGHT_PX; + let live = canvas_render_plan(CanvasPlanInput { + css_width: 960, + css_height: 360, + dpr: 2.0, + is_live: true, + scroll_top_px: 0, + total_px, + offset_px, + vp_rows: 20, + line_height_px: DEFAULT_LINE_HEIGHT_PX, + }); + + assert_eq!(live.dom_state.backing_width, 1920); + assert_eq!(live.dom_state.backing_height, 720); + assert_eq!(live.dom_state.css_width, 960); + assert_eq!(live.dom_state.css_height, 360); + assert_eq!( + live.dom_state.top_px, + total_px - 20.0 * DEFAULT_LINE_HEIGHT_PX + ); + assert_eq!(live.content_top_px, 0.0); + + let history = canvas_render_plan(CanvasPlanInput { + css_width: 960, + css_height: 360, + dpr: 1.5, + is_live: false, + scroll_top_px: 180, + total_px, + offset_px: 90.0, + vp_rows: 20, + line_height_px: DEFAULT_LINE_HEIGHT_PX, + }); + assert_eq!(history.dom_state.backing_width, 1440); + assert_eq!(history.dom_state.backing_height, 540); + assert_eq!(history.dom_state.top_px, 180.0); + assert_eq!(history.content_top_px, -90.0); + } + + #[test] + fn cursor_cell_text_finds_segment_by_column() { + let row = TerminalRow { + segments: vec![ + TerminalSegment { + text: "ab".to_owned(), + cols: 2, + style: TerminalStyle::default(), + is_cursor: false, + }, + TerminalSegment { + text: "界".to_owned(), + cols: 2, + style: TerminalStyle::default(), + is_cursor: false, + }, + ], + }; + + assert_eq!(cursor_cell_text(&row, 0).as_deref(), Some("ab")); + assert_eq!(cursor_cell_text(&row, 2).as_deref(), Some("界")); + assert_eq!(cursor_cell_text(&row, 3).as_deref(), Some("界")); + assert_eq!(cursor_cell_text(&row, 4), None); + } +}