import { dbg_assert } from "../log.js"; import { get_charmap } from "../lib.js"; // Draws entire buffer and visualizes the layers that would be drawn export const DEBUG_SCREEN_LAYERS = DEBUG && false; /** * Adapter to use visual screen in browsers (in contrast to node) * @constructor * @param {Object} options * @param {function()} screen_fill_buffer */ export function ScreenAdapter(options, screen_fill_buffer) { const screen_container = options.container; this.screen_fill_buffer = screen_fill_buffer; console.assert(screen_container, "options.container must be provided"); const MODE_TEXT = 0; const MODE_GRAPHICAL = 1; const MODE_GRAPHICAL_TEXT = 2; const CHARACTER_INDEX = 0; const FLAGS_INDEX = 1; const BG_COLOR_INDEX = 2; const FG_COLOR_INDEX = 3; const TEXT_BUF_COMPONENT_SIZE = 4; const FLAG_BLINKING = 0x01; const FLAG_FONT_PAGE_B = 0x02; this.FLAG_BLINKING = FLAG_BLINKING; this.FLAG_FONT_PAGE_B = FLAG_FONT_PAGE_B; var graphic_screen = screen_container.getElementsByTagName("canvas")[0], graphic_context = graphic_screen.getContext("2d", { alpha: false }), text_screen = screen_container.getElementsByTagName("div")[0], cursor_element = document.createElement("div"); var /** @type {number} */ cursor_row, /** @type {number} */ cursor_col, /** @type {number} */ scale_x = options.scale !== undefined ? options.scale : 1, /** @type {number} */ scale_y = options.scale !== undefined ? options.scale : 1, base_scale = 1, changed_rows, // current display mode: MODE_GRAPHICAL or either MODE_TEXT/MODE_GRAPHICAL_TEXT mode, // Index 0: ASCII code // Index 1: Flags bitset (see FLAG_...) // Index 2: Background color // Index 3: Foreground color text_mode_data, // number of columns text_mode_width, // number of rows text_mode_height, // graphical text mode's offscreen canvas contexts offscreen_context, offscreen_extra_context, // fonts font_context, font_image_data, font_is_visible = new Int8Array(8 * 256), font_height, font_width, font_width_9px, font_width_dbl, font_copy_8th_col, font_page_a = 0, font_page_b = 0, // blink state blink_visible, tm_last_update = 0, // cursor attributes cursor_start, cursor_end, cursor_enabled, // 8-bit-text to Unicode character map charmap = get_charmap(options.encoding), // render loop state timer_id = 0, paused = false; // 0x12345 -> "#012345" function number_as_color(n) { n = n.toString(16); return "#" + "0".repeat(6 - n.length) + n; } function render_font_bitmap(vga_bitmap) { // - Browsers impose limts on the X- and Y-axes of bitmaps (typically around 8 to 32k). // Draw the 8 VGA font pages of 256 glyphs in 8 rows of 256 columns, this results // in 2048, 2304 or 4096px on the X-axis (for 8, 9 or 16px VGA font width, resp.). // This 2d layout is also convenient for glyph lookup when rendering text. // - Font bitmap pixels are black and either fully opaque (alpha 255) or fully transparent (0). const bitmap_width = font_width * 256; const bitmap_height = font_height * 8; let font_canvas = font_context ? font_context.canvas : null; if(!font_canvas || font_canvas.width !== bitmap_width || font_canvas.height !== bitmap_height) { if(!font_canvas) { font_canvas = new OffscreenCanvas(bitmap_width, bitmap_height); font_context = font_canvas.getContext("2d"); } else { font_canvas.width = bitmap_width; font_canvas.height = bitmap_height; } font_image_data = font_context.createImageData(bitmap_width, bitmap_height); } const font_bitmap = font_image_data.data; let i_dst = 0, is_visible; const put_bit = font_width_dbl ? function(value) { is_visible = is_visible || value; font_bitmap[i_dst + 3] = value; font_bitmap[i_dst + 7] = value; i_dst += 8; } : function(value) { is_visible = is_visible || value; font_bitmap[i_dst + 3] = value; i_dst += 4; }; // move i_vga from end of glyph to start of next glyph const vga_inc_chr = 32 - font_height; // move i_dst from end of font page (bitmap row) to start of next font page const dst_inc_row = bitmap_width * (font_height - 1) * 4; // move i_dst from end of glyph (bitmap column) to start of next glyph const dst_inc_col = (font_width - bitmap_width * font_height) * 4; // move i_dst from end of a glyph's scanline to start of its next scanline const dst_inc_line = font_width * 255 * 4; for(let i_chr_all = 0, i_vga = 0; i_chr_all < 2048; ++i_chr_all, i_vga += vga_inc_chr, i_dst += dst_inc_col) { const i_chr = i_chr_all % 256; if(i_chr_all && !i_chr) { i_dst += dst_inc_row; } is_visible = false; for(let i_line = 0; i_line < font_height; ++i_line, ++i_vga, i_dst += dst_inc_line) { const line_bits = vga_bitmap[i_vga]; for(let i_bit = 0x80; i_bit > 0; i_bit >>= 1) { put_bit(line_bits & i_bit ? 255 : 0); } if(font_width_9px) { put_bit(font_copy_8th_col && i_chr >= 0xC0 && i_chr <= 0xDF && line_bits & 1 ? 255 : 0); } } font_is_visible[i_chr_all] = is_visible ? 1 : 0; } font_context.putImageData(font_image_data, 0, 0); } function render_changed_rows() { const font_canvas = font_context.canvas; const offscreen_extra_canvas = offscreen_extra_context.canvas; const txt_row_size = text_mode_width * TEXT_BUF_COMPONENT_SIZE; const gfx_width = text_mode_width * font_width; const row_extra_1_y = 0; const row_extra_2_y = font_height; let n_rows_rendered = 0; for(let row_i = 0, row_y = 0, txt_i = 0; row_i < text_mode_height; ++row_i, row_y += font_height) { if(!changed_rows[row_i]) { txt_i += txt_row_size; continue; } ++n_rows_rendered; // clear extra row 2 offscreen_extra_context.clearRect(0, row_extra_2_y, gfx_width, font_height); let fg_rgba, fg_x, bg_rgba, bg_x; for(let col_x = 0; col_x < gfx_width; col_x += font_width, txt_i += TEXT_BUF_COMPONENT_SIZE) { const chr = text_mode_data[txt_i + CHARACTER_INDEX]; const chr_flags = text_mode_data[txt_i + FLAGS_INDEX]; const chr_bg_rgba = text_mode_data[txt_i + BG_COLOR_INDEX]; const chr_fg_rgba = text_mode_data[txt_i + FG_COLOR_INDEX]; const chr_font_page = chr_flags & FLAG_FONT_PAGE_B ? font_page_b : font_page_a; const chr_visible = (!(chr_flags & FLAG_BLINKING) || blink_visible) && font_is_visible[(chr_font_page << 8) + chr]; if(bg_rgba !== chr_bg_rgba) { if(bg_rgba !== undefined) { // draw opaque block of background color into offscreen_context offscreen_context.fillStyle = number_as_color(bg_rgba); offscreen_context.fillRect(bg_x, row_y, col_x - bg_x, font_height); } bg_rgba = chr_bg_rgba; bg_x = col_x; } if(fg_rgba !== chr_fg_rgba) { if(fg_rgba !== undefined) { // draw opaque block of foreground color into extra row 1 offscreen_extra_context.fillStyle = number_as_color(fg_rgba); offscreen_extra_context.fillRect(fg_x, row_extra_1_y, col_x - fg_x, font_height); } fg_rgba = chr_fg_rgba; fg_x = col_x; } if(chr_visible) { // copy transparent glyphs into extra row 2 offscreen_extra_context.drawImage(font_canvas, chr * font_width, chr_font_page * font_height, font_width, font_height, col_x, row_extra_2_y, font_width, font_height); } } // draw rightmost block of foreground color into extra row 1 offscreen_extra_context.fillStyle = number_as_color(fg_rgba); offscreen_extra_context.fillRect(fg_x, row_extra_1_y, gfx_width - fg_x, font_height); // combine extra row 1 (colors) and 2 (glyphs) into extra row 1 (colored glyphs) offscreen_extra_context.globalCompositeOperation = "destination-in"; offscreen_extra_context.drawImage(offscreen_extra_canvas, 0, row_extra_2_y, gfx_width, font_height, 0, row_extra_1_y, gfx_width, font_height); offscreen_extra_context.globalCompositeOperation = "source-over"; // draw rightmost block of background color into offscreen_context offscreen_context.fillStyle = number_as_color(bg_rgba); offscreen_context.fillRect(bg_x, row_y, gfx_width - bg_x, font_height); // copy colored glyphs from extra row 1 into offscreen_context (on top of background colors) offscreen_context.drawImage(offscreen_extra_canvas, 0, row_extra_1_y, gfx_width, font_height, 0, row_y, gfx_width, font_height); } if(n_rows_rendered) { if(blink_visible && cursor_enabled && changed_rows[cursor_row]) { const cursor_txt_i = (cursor_row * text_mode_width + cursor_col) * TEXT_BUF_COMPONENT_SIZE; const cursor_rgba = text_mode_data[cursor_txt_i + FG_COLOR_INDEX]; offscreen_context.fillStyle = number_as_color(cursor_rgba); offscreen_context.fillRect( cursor_col * font_width, cursor_row * font_height + cursor_start, font_width, cursor_end - cursor_start + 1); } changed_rows.fill(0); } return n_rows_rendered; } function mark_blinking_rows_dirty() { const txt_row_size = text_mode_width * TEXT_BUF_COMPONENT_SIZE; for(let row_i = 0, txt_i = 0; row_i < text_mode_height; ++row_i) { if(changed_rows[row_i]) { txt_i += txt_row_size; continue; } for(let col_i = 0; col_i < text_mode_width; ++col_i, txt_i += TEXT_BUF_COMPONENT_SIZE) { if(text_mode_data[txt_i + FLAGS_INDEX] & FLAG_BLINKING) { changed_rows[row_i] = 1; txt_i += txt_row_size - col_i * TEXT_BUF_COMPONENT_SIZE; break; } } } } this.init = function() { // setup text mode cursor DOM element cursor_element.classList.add("cursor"); cursor_element.style.position = "absolute"; cursor_element.style.backgroundColor = "#ccc"; cursor_element.style.width = "7px"; cursor_element.style.display = "inline-block"; // initialize display mode and size to 80x25 text with 9x16 font this.set_mode(false); this.set_size_text(80, 25); if(mode === MODE_GRAPHICAL_TEXT) { this.set_size_graphical(720, 400, 720, 400); } // initialize CSS scaling this.set_scale(scale_x, scale_y); this.timer(); }; this.make_screenshot = function() { const image = new Image(); if(mode === MODE_GRAPHICAL || mode === MODE_GRAPHICAL_TEXT) { image.src = graphic_screen.toDataURL("image/png"); } else { // Default 720x400, but can be [8, 16] at 640x400 const char_size = [9, 16]; const canvas = document.createElement("canvas"); canvas.width = text_mode_width * char_size[0]; canvas.height = text_mode_height * char_size[1]; const context = canvas.getContext("2d"); context.imageSmoothingEnabled = false; context.font = window.getComputedStyle(text_screen).font; context.textBaseline = "top"; for(let y = 0; y < text_mode_height; y++) { for(let x = 0; x < text_mode_width; x++) { const index = (y * text_mode_width + x) * TEXT_BUF_COMPONENT_SIZE; const character = text_mode_data[index + CHARACTER_INDEX]; const bg_color = text_mode_data[index + BG_COLOR_INDEX]; const fg_color = text_mode_data[index + FG_COLOR_INDEX]; context.fillStyle = number_as_color(bg_color); context.fillRect(x * char_size[0], y * char_size[1], char_size[0], char_size[1]); context.fillStyle = number_as_color(fg_color); context.fillText(charmap[character], x * char_size[0], y * char_size[1]); } } if(cursor_element.style.display !== "none" && cursor_row < text_mode_height && cursor_col < text_mode_width) { context.fillStyle = cursor_element.style.backgroundColor; context.fillRect( cursor_col * char_size[0], cursor_row * char_size[1] + parseInt(cursor_element.style.marginTop, 10), parseInt(cursor_element.style.width, 10), parseInt(cursor_element.style.height, 10) ); } image.src = canvas.toDataURL("image/png"); } return image; }; this.put_char = function(row, col, chr, flags, bg_color, fg_color) { dbg_assert(row >= 0 && row < text_mode_height); dbg_assert(col >= 0 && col < text_mode_width); dbg_assert(chr >= 0 && chr < 0x100); const p = TEXT_BUF_COMPONENT_SIZE * (row * text_mode_width + col); text_mode_data[p + CHARACTER_INDEX] = chr; text_mode_data[p + FLAGS_INDEX] = flags; text_mode_data[p + BG_COLOR_INDEX] = bg_color; text_mode_data[p + FG_COLOR_INDEX] = fg_color; changed_rows[row] = 1; }; this.timer = function() { timer_id = requestAnimationFrame(() => this.update_screen()); }; this.update_screen = function() { if(!paused) { if(mode === MODE_TEXT) { this.update_text(); } else if(mode === MODE_GRAPHICAL) { this.update_graphical(); } else { this.update_graphical_text(); } } this.timer(); }; this.update_text = function() { for(var i = 0; i < text_mode_height; i++) { if(changed_rows[i]) { this.text_update_row(i); changed_rows[i] = 0; } } }; this.update_graphical = function() { this.screen_fill_buffer(); }; this.update_graphical_text = function() { if(offscreen_context) { // toggle cursor and blinking character visibility at a frequency of ~3.75hz const tm_now = performance.now(); if(tm_now - tm_last_update > 266) { blink_visible = !blink_visible; if(cursor_enabled) { changed_rows[cursor_row] = 1; } mark_blinking_rows_dirty(); tm_last_update = tm_now; } // copy to DOM canvas only if anything new was rendered if(render_changed_rows()) { graphic_context.drawImage(offscreen_context.canvas, 0, 0); } } }; this.destroy = function() { if(timer_id) { cancelAnimationFrame(timer_id); timer_id = 0; } }; this.pause = function() { paused = true; cursor_element.classList.remove("blinking-cursor"); }; this.continue = function() { paused = false; cursor_element.classList.add("blinking-cursor"); }; this.set_mode = function(graphical) { mode = graphical ? MODE_GRAPHICAL : (options.use_graphical_text ? MODE_GRAPHICAL_TEXT : MODE_TEXT); if(mode === MODE_TEXT) { text_screen.style.display = "block"; graphic_screen.style.display = "none"; } else { text_screen.style.display = "none"; graphic_screen.style.display = "block"; if(mode === MODE_GRAPHICAL_TEXT && changed_rows) { changed_rows.fill(1); } } }; this.set_font_bitmap = function(height, width_9px, width_dbl, copy_8th_col, vga_bitmap, vga_bitmap_changed) { const width = width_dbl ? 16 : (width_9px ? 9 : 8); if(font_height !== height || font_width !== width || font_width_9px !== width_9px || font_width_dbl !== width_dbl || font_copy_8th_col !== copy_8th_col || vga_bitmap_changed) { const size_changed = font_width !== width || font_height !== height; font_height = height; font_width = width; font_width_9px = width_9px; font_width_dbl = width_dbl; font_copy_8th_col = copy_8th_col; if(mode === MODE_GRAPHICAL_TEXT) { render_font_bitmap(vga_bitmap); changed_rows.fill(1); if(size_changed) { this.set_size_graphical_text(); } } } }; this.set_font_page = function(page_a, page_b) { if(font_page_a !== page_a || font_page_b !== page_b) { font_page_a = page_a; font_page_b = page_b; changed_rows.fill(1); } }; this.clear_screen = function() { graphic_context.fillStyle = "#000"; graphic_context.fillRect(0, 0, graphic_screen.width, graphic_screen.height); }; this.set_size_graphical_text = function() { if(!font_context) { return; } const gfx_width = font_width * text_mode_width; const gfx_height = font_height * text_mode_height; const offscreen_extra_height = font_height * 2; if(!offscreen_context || offscreen_context.canvas.width !== gfx_width || offscreen_context.canvas.height !== gfx_height || offscreen_extra_context.canvas.height !== offscreen_extra_height) { // resize offscreen canvases if(!offscreen_context) { const offscreen_canvas = new OffscreenCanvas(gfx_width, gfx_height); offscreen_context = offscreen_canvas.getContext("2d", { alpha: false }); const offscreen_extra_canvas = new OffscreenCanvas(gfx_width, offscreen_extra_height); offscreen_extra_context = offscreen_extra_canvas.getContext("2d"); } else { offscreen_context.canvas.width = gfx_width; offscreen_context.canvas.height = gfx_height; offscreen_extra_context.canvas.width = gfx_width; offscreen_extra_context.canvas.height = offscreen_extra_height; } // resize DOM canvas graphic_screen this.set_size_graphical(gfx_width, gfx_height, gfx_width, gfx_height); changed_rows.fill(1); } }; /** * @param {number} cols * @param {number} rows */ this.set_size_text = function(cols, rows) { if(cols === text_mode_width && rows === text_mode_height) { return; } changed_rows = new Int8Array(rows); text_mode_data = new Int32Array(cols * rows * TEXT_BUF_COMPONENT_SIZE); text_mode_width = cols; text_mode_height = rows; if(mode === MODE_TEXT) { while(text_screen.childNodes.length > rows) { text_screen.removeChild(text_screen.firstChild); } while(text_screen.childNodes.length < rows) { text_screen.appendChild(document.createElement("div")); } for(var i = 0; i < rows; i++) { this.text_update_row(i); } update_scale_text(); } else if(mode === MODE_GRAPHICAL_TEXT) { this.set_size_graphical_text(); } }; this.set_size_graphical = function(width, height, buffer_width, buffer_height) { if(DEBUG_SCREEN_LAYERS) { // Draw the entire buffer. Useful for debugging // panning / page flipping / screen splitting code for both // v86 developers and os developers width = buffer_width; height = buffer_height; } graphic_screen.style.display = "block"; graphic_screen.width = width; graphic_screen.height = height; // graphic_context must be reconfigured whenever its graphic_screen is resized graphic_context.imageSmoothingEnabled = false; // add some scaling to tiny resolutions if(width <= 640 && width * 2 < window.innerWidth * window.devicePixelRatio && height * 2 < window.innerHeight * window.devicePixelRatio) { base_scale = 2; } else { base_scale = 1; } update_scale_graphic(); }; this.set_scale = function(s_x, s_y) { scale_x = s_x; scale_y = s_y; update_scale_text(); update_scale_graphic(); }; function update_scale_text() { elem_set_scale(text_screen, scale_x, scale_y, true); } function update_scale_graphic() { elem_set_scale(graphic_screen, scale_x * base_scale, scale_y * base_scale, false); } function elem_set_scale(elem, scale_x, scale_y, use_scale) { if(!scale_x || !scale_y) { return; } elem.style.width = ""; elem.style.height = ""; if(use_scale) { elem.style.transform = ""; } var rectangle = elem.getBoundingClientRect(); if(use_scale) { var scale_str = ""; scale_str += scale_x === 1 ? "" : " scaleX(" + scale_x + ")"; scale_str += scale_y === 1 ? "" : " scaleY(" + scale_y + ")"; elem.style.transform = scale_str; } else { // unblur non-fractional scales if(scale_x % 1 === 0 && scale_y % 1 === 0) { graphic_screen.style["imageRendering"] = "crisp-edges"; // firefox graphic_screen.style["imageRendering"] = "pixelated"; graphic_screen.style["-ms-interpolation-mode"] = "nearest-neighbor"; } else { graphic_screen.style["imageRendering"] = ""; graphic_screen.style["-ms-interpolation-mode"] = ""; } // undo fractional css-to-device pixel ratios var device_pixel_ratio = window.devicePixelRatio || 1; if(device_pixel_ratio % 1 !== 0) { scale_x /= device_pixel_ratio; scale_y /= device_pixel_ratio; } } if(scale_x !== 1) { elem.style.width = rectangle.width * scale_x + "px"; } if(scale_y !== 1) { elem.style.height = rectangle.height * scale_y + "px"; } } this.update_cursor_scanline = function(start, end, enabled) { if(start !== cursor_start || end !== cursor_end || enabled !== cursor_enabled) { if(mode === MODE_TEXT) { if(enabled) { cursor_element.style.display = "inline"; cursor_element.style.height = (end - start) + "px"; cursor_element.style.marginTop = start + "px"; } else { cursor_element.style.display = "none"; } } else if(mode === MODE_GRAPHICAL_TEXT) { if(cursor_row < text_mode_height) { changed_rows[cursor_row] = 1; } } cursor_start = start; cursor_end = end; cursor_enabled = enabled; } }; this.update_cursor = function(row, col) { if(row !== cursor_row || col !== cursor_col) { if(row < text_mode_height) { changed_rows[row] = 1; } if(cursor_row < text_mode_height) { changed_rows[cursor_row] = 1; } cursor_row = row; cursor_col = col; } }; this.text_update_row = function(row) { var offset = TEXT_BUF_COMPONENT_SIZE * row * text_mode_width, row_element, color_element, fragment; var blinking, bg_color, fg_color, text; row_element = text_screen.childNodes[row]; fragment = document.createElement("div"); for(var i = 0; i < text_mode_width; ) { color_element = document.createElement("span"); blinking = text_mode_data[offset + FLAGS_INDEX] & FLAG_BLINKING; bg_color = text_mode_data[offset + BG_COLOR_INDEX]; fg_color = text_mode_data[offset + FG_COLOR_INDEX]; if(blinking) { color_element.classList.add("blink"); } color_element.style.backgroundColor = number_as_color(bg_color); color_element.style.color = number_as_color(fg_color); text = ""; // put characters of the same color in one element while(i < text_mode_width && (text_mode_data[offset + FLAGS_INDEX] & FLAG_BLINKING) === blinking && text_mode_data[offset + BG_COLOR_INDEX] === bg_color && text_mode_data[offset + FG_COLOR_INDEX] === fg_color) { const chr = charmap[text_mode_data[offset + CHARACTER_INDEX]]; text += chr; dbg_assert(chr); i++; offset += TEXT_BUF_COMPONENT_SIZE; if(row === cursor_row) { if(i === cursor_col) { // next row will be cursor // create new element break; } else if(i === cursor_col + 1) { // found the cursor cursor_element.style.backgroundColor = color_element.style.color; fragment.appendChild(cursor_element); break; } } } color_element.textContent = text; fragment.appendChild(color_element); } row_element.parentNode.replaceChild(fragment, row_element); }; this.update_buffer = function(layers) { if(DEBUG_SCREEN_LAYERS) { // For each visible layer that would've been drawn, draw a // rectangle to visualise the layer instead. graphic_context.strokeStyle = "#0F0"; graphic_context.lineWidth = 4; for(const layer of layers) { graphic_context.strokeRect( layer.buffer_x, layer.buffer_y, layer.buffer_width, layer.buffer_height ); } graphic_context.lineWidth = 1; return; } for(const layer of layers) { graphic_context.putImageData( layer.image_data, layer.screen_x - layer.buffer_x, layer.screen_y - layer.buffer_y, layer.buffer_x, layer.buffer_y, layer.buffer_width, layer.buffer_height ); } }; // XXX: duplicated in DummyScreenAdapter this.get_text_screen = function() { var screen = []; for(var i = 0; i < text_mode_height; i++) { screen.push(this.get_text_row(i)); } return screen; }; this.get_text_row = function(y) { const begin = y * text_mode_width * TEXT_BUF_COMPONENT_SIZE + CHARACTER_INDEX; const end = begin + text_mode_width * TEXT_BUF_COMPONENT_SIZE; let row = ""; for(let i = begin; i < end; i += TEXT_BUF_COMPONENT_SIZE) { row += charmap[text_mode_data[i]]; } return row; }; this.init(); }