From 0d4e10f5ab8281b90b0a92d38bbebb6d80359dff Mon Sep 17 00:00:00 2001 From: Leroy Hopson Date: Tue, 19 May 2020 18:45:18 +0700 Subject: [PATCH] Add more features, bug fixes and bugs ;-) Most notably: - Reflow is now working. Terminal size will fill the window and cols/rows will be resized/calculated based on window and font size. - Added support for different fonts (i.e. bold, italic, bolditalic). - Enabled blinking characters. - Adde more tests and caught a few subtle bugs. - Removed renderer code (which was part of xterm.js) and just doing naive rendering in terminal.gd, but it seems to perform a lot faster. Still not working completely: - vim (some weirdness going on). - vttest (more weirdness). Todo: - Fix the above. - Draw the cursor! - Improve performance. Performance is still not great. The terminal becomes unusable when running `yes` or `cmatrix -r`. --- .test.txt.swp | Bin 12288 -> 0 bytes addons/godot_xterm/buffer.gd | 271 ------- addons/godot_xterm/buffer/attribute_data.gd | 45 +- addons/godot_xterm/buffer/buffer.gd | 313 +++++++- addons/godot_xterm/buffer/buffer_line.gd | 48 +- addons/godot_xterm/buffer/buffer_reflow.gd | 198 +++++ addons/godot_xterm/buffer/buffer_set.gd | 18 +- addons/godot_xterm/buffer/constants.gd | 13 + addons/godot_xterm/circular_list.gd | 118 ++- addons/godot_xterm/color_manager.gd | 4 +- addons/godot_xterm/data/charsets.gd | 246 +++++- .../source_code_pro_regular.tres | 1 - addons/godot_xterm/input_handler.gd | 737 ++++++++++++++---- .../parser/escape_sequence_parser.gd | 16 +- addons/godot_xterm/parser/params.gd | 25 +- .../godot_xterm/renderer/base_render_layer.gd | 250 ------ addons/godot_xterm/renderer/renderer.gd | 118 --- .../godot_xterm/renderer/text_render_layer.gd | 191 ----- addons/godot_xterm/services/buffer_service.gd | 16 +- .../godot_xterm/services/options_service.gd | 127 ++- addons/godot_xterm/terminal.gd | 181 ++++- demo.cast | 61 ++ scenes/demo.gd | 8 +- scenes/demo.tscn | 29 +- test/integration/test_terminal.gd | 6 - test/unit/buffer/test_buffer.gd | 234 ++++++ test/unit/buffer/test_buffer_line.gd | 94 ++- test/unit/test_circular_list.gd | 201 +++++ test/unit/test_input_handler.gd | 170 ++-- test/unit/test_params.gd | 58 +- 30 files changed, 2640 insertions(+), 1157 deletions(-) delete mode 100644 .test.txt.swp delete mode 100644 addons/godot_xterm/buffer.gd create mode 100644 addons/godot_xterm/buffer/buffer_reflow.gd delete mode 100644 addons/godot_xterm/renderer/base_render_layer.gd delete mode 100644 addons/godot_xterm/renderer/renderer.gd delete mode 100644 addons/godot_xterm/renderer/text_render_layer.gd create mode 100644 demo.cast create mode 100644 test/unit/test_circular_list.gd diff --git a/.test.txt.swp b/.test.txt.swp deleted file mode 100644 index 32eafcc59e5568eafbbdc37082b988de16490fff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI%yAFad6oBF4ZZ!G=sI%N$d<6$b+*KOI8wAdPi$1B3Vj>`nV!}r9H))&RPENnA z>CV}#*5=Z3RbGZg^DUOA)I}%Jy`E_3yz1td1qGtawM z5I_I{1Q0*~0R#|00D(%N__coxlzQj2`2Vlp|K%7Z1Q0*~0R#|0 S009ILKmY**5a_mJAe diff --git a/addons/godot_xterm/buffer.gd b/addons/godot_xterm/buffer.gd deleted file mode 100644 index ea65fc7..0000000 --- a/addons/godot_xterm/buffer.gd +++ /dev/null @@ -1,271 +0,0 @@ -# Copyright (c) 2020 The GodotXterm authors. All rights reserved. -# License MIT -extends Reference - - -const CharData = preload("res://addons/godot_xterm/char_data.gd") -const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd") - -const MAX_BUFFER_SIZE = 32768 # 32 KiB - - - -# Erase in Line (EL) -enum {EL_RIGHT, EL_LEFT, EL_ALL} -enum {FONT_NORMAL, FONT_BOLD, FONT_BLINK} - -# Places a tab stop after every 8 columns. -var tabWidth = 8 - -var rows = [[]] # array of CharData - -var fg = Color(1.0, 1.0, 1.0) # foreground color -var bg = Color(0.0, 0.0, 0.0) # background color -var font # font -var font_flags = FONT_NORMAL - -var crow = 0 setget _set_crow # cursor's row -var ccol = 0 setget _set_ccol # cursor's column - -var ccol_saved: int -var crow_saved: int - -var num_rows = 20 -var num_cols = 70 - -var savedBuffer -var savedCursorRow -var savedCursorCol - -func _init(num_rows: int, num_columns: int, alternate: bool = false): - rows = [] - rows.resize(num_rows) - for i in range(rows.size()): - var cols = [] - cols.resize(num_columns) - for j in range(cols.size()): - cols[j] = CharData.new(" ", bg, fg, font_flags) - rows[i] = cols - -func _get_buffer_size(): - # Get the size of the (virtual) buffer. - # Count each CharData as one byte even though it might be multiple bytes - # in the case of unicode characters. - var size = 0 - for row in rows: - size += row.size() - return size - -func _set_rows(rows): - print("rows: ", rows) - -func _set_crow(row: int): - print("setting crow") - # Ensure there are enoungh rows in the - # buffer for the new cursor position. - if row >= rows.size(): - rows.resize(row + 1) - - # resize() uses null for new elements. - # but a row should be an array so we - # need to replace null values. - for i in range(rows.size()): - if rows[i] == null: - rows[i] = [] - - crow = row - -func _set_ccol(col: int): - # Ensure there are enough columns in the - # row for the new cursor position. - print("da size: ", rows[crow].size()) - if col >= rows[crow].size(): - rows[crow].resize(col + 1) - - print("da new size: ", rows[crow].size()) - - for i in range(rows[crow].size()): - if rows[crow][i] == null: - rows[crow][i] = CharData.new(' ', bg, fg) - - ccol = col - -func save_cursor(): - ccol_saved = ccol - crow_saved = crow - -func restore_cursor(): - ccol = ccol_saved - crow = crow_saved - -func insert_at_cursor(d, start: int = 0, end: int = 1): - var string - if typeof(d) == TYPE_ARRAY: - string = Decoder.utf32_to_string(d.slice(start, end - 1)) - else: - string = d - - var row = rows[crow] - - for i in range(string.length()): - var data = CharData.new(string[i], bg, fg, font_flags) - - if ccol < row.size(): - row[ccol] = data - else: - row.resize(ccol) - - for i in range(row.size()): - if row[i] == null: - row[i] = CharData.new(' ', bg, fg, font_flags) - - row.append(data) - - # Update the cursor position. - ccol += 1 - -func insert_tab(): - print("Insert a tab!") - # Insert a space. - insert_at_cursor(' ') - - # Keep inserting spaces until cursor is at next Tab stop. - while ccol % tabWidth != 0: - insert_at_cursor(' ') - -# cr -func carriage_return(): - ccol = 0 - -# lf -func line_feed(): - rows.resize(rows.size() + 1) - rows[-1] = [] - crow = crow + 1 - -# bs -# Deletes the element before the current cursor position. -func backspace(): - rows[crow].remove(ccol - 1) - ccol = ccol - 1 - -# cup -# Move the cursor to the given row and column. -# For example cursor_position(0, 0) would move -# the cursor to the top left corner of the terminal. -func cursor_position(params: Array) -> void: - var row = params[0] if params.size() > 0 else 1 - var col = params[1] if params.size() > 1 else 1 - - # Origin is (0,0) so row 1, col 1 would be 0,0. - if col != 0: - self.ccol = col - 1 - else: - self.ccol = 0 - if row != 0: - self.crow = row - 1 - else: - self.crow = 0 - -# ed 3 -func erase_saved_lines(): - rows = [[]] - print("saved lines erased") - -# el -func erase_in_line(section): - return - match section: - EL_LEFT, EL_ALL: - for i in range(0, ccol): - rows[crow][i] = CharData.new(" ") - print("Erased the thing") - if section == EL_ALL: - continue - EL_RIGHT, _: - for i in range(ccol, rows[crow].size()): - rows[crow][i] = CharData.new(" ") - print("Erased the thing") - -# ed 0 (default) -func erase_below(): - # Erase from the cursor through to the end of the display. - save_cursor() - while crow < num_rows: - erase_in_line(EL_RIGHT) - _set_ccol(0) - _set_crow(crow + 1) - restore_cursor() - -func set_scrolling_region(top: int, bottom: int): - print("set_scrolling_position") - # Not sure what this does yet. - # Make default be full window size. - pass - -func set_font(fontState: int, set: bool = true): - match fontState: - FONT_NORMAL: - pass - -func set_font_flag(flag: int, set: bool = true): - print("setting font flag!") - if set: # Set the flag - font_flags |= (1 << flag) - else: # Clear the flag - font_flags &= ~(1 << flag) - print("font flag is set!") - print(font_flags) - -# Clear all font flags. Returns font to default state. -func reset_font_flags(): - font_flags = FONT_NORMAL - -# setf -func set_foreground(color: Color = Color(1.0, 1.0, 1.0)): - fg = color - -# setb -func set_background(color: Color = Color(0.0, 0.0, 0.0)): - bg = color - -# setaf -func set_a_foreground(params): - pass - -# setab -func set_a_background(params): - pass - -func reset_sgr(): - set_foreground() - set_background() - reset_font_flags() - -func repeat_preceding_character(times: int = 0): - - var preceding_char - - if ccol == 0: - preceding_char = rows[crow-1][-1] - else: - preceding_char = rows[crow][ccol-1] - - print("Repeating preceding char ", preceding_char.ch, " ", times, " times") - - for i in range(times): - insert_at_cursor(preceding_char.ch) - -# Save the buffer (useful when switching to the alternate buffer) -func save(): - savedBuffer = rows - savedCursorCol = ccol - savedCursorRow = crow - -# Full Reset -func reset(): - rows = [[]] - crow = 0 - ccol = 0 - fg = Color(1.0, 1.0, 1.0) - bg = Color(0.0, 0.0, 0.0) diff --git a/addons/godot_xterm/buffer/attribute_data.gd b/addons/godot_xterm/buffer/attribute_data.gd index d22bd04..5198725 100644 --- a/addons/godot_xterm/buffer/attribute_data.gd +++ b/addons/godot_xterm/buffer/attribute_data.gd @@ -15,6 +15,12 @@ var bg = 0 var extended = ExtendedAttrs.new() +static func to_color_rgb(value: int) -> Color: + # Create color from RGB format. + return Color("%02x%02x%02x" % [value >> Attributes.RED_SHIFT & 255, + value >> Attributes.GREEN_SHIFT & 255, value & 255]) + + # flags func is_inverse() -> int: return fg & FgFlags.INVERSE @@ -27,7 +33,7 @@ func is_blink() -> int: func is_invisible() -> int: return fg & FgFlags.INVISIBLE func is_italic() -> int: - return fg & BgFlags.ITALIC + return bg & BgFlags.ITALIC func is_dim() -> int: return fg & BgFlags.DIM @@ -60,13 +66,30 @@ func get_fg_color() -> int: Attributes.CM_RGB: return fg & Attributes.RGB_MASK _: - return -1 + return -1 # CM_DEFAULT defaults to -1 + + +func get_bg_color() -> int: + match bg & Attributes.CM_MASK: + Attributes.CM_P16, Attributes.CM_P256: + return bg & Attributes.PCOLOR_MASK + Attributes.CM_RGB: + return bg & Attributes.RGB_MASK + _: + return -1 # CM_DEFAULT defaults to -1 func has_extended_attrs() -> int: return bg & BgFlags.HAS_EXTENDED +func update_extended() -> void: + if extended.is_empty(): + bg &= ~BgFlags.HAS_EXTENDED + else: + bg |= BgFlags.HAS_EXTENDED + + func get_underline_color() -> int: if bg & BgFlags.HAS_EXTENDED and ~extended.underline_color: match extended.underline_color & Attributes.CM_MASK: @@ -117,6 +140,9 @@ func get_underline_style(): class ExtendedAttrs: + extends Reference + # Extended attributes for a cell. + # Holds information about different underline styles and color. var underline_style = UnderlineStyle.NONE @@ -125,3 +151,18 @@ class ExtendedAttrs: func _init(): underline_style + + + func duplicate(): + # Workaround as per: https://github.com/godotengine/godot/issues/19345#issuecomment-471218401 + var AttributeData = load("res://addons/godot_xterm/buffer/attribute_data.gd") + var clone = AttributeData.ExtendedAttrs.new() + clone.underline_style = underline_style + clone.underline_color = underline_color + return clone + + + # Convenient method to indicate whether the object holds no additional information, + # that needs to be persistant in the buffer. + func is_empty(): + return underline_style == UnderlineStyle.NONE diff --git a/addons/godot_xterm/buffer/buffer.gd b/addons/godot_xterm/buffer/buffer.gd index 19a3321..2c81ba8 100644 --- a/addons/godot_xterm/buffer/buffer.gd +++ b/addons/godot_xterm/buffer/buffer.gd @@ -10,7 +10,7 @@ const Charsets = preload("res://addons/godot_xterm/data/charsets.gd") const Constants = preload("res://addons/godot_xterm/buffer/constants.gd") const CircularList = preload("res://addons/godot_xterm/circular_list.gd") const AttributeData = preload("res://addons/godot_xterm/buffer/attribute_data.gd") - +const BufferReflow = preload("res://addons/godot_xterm/buffer/buffer_reflow.gd") const MAX_BUFFER_SIZE = 4294967295 # 2^32 - 1 @@ -51,6 +51,286 @@ func _init(has_scrollback: bool, options_service, buffer_service): setup_tab_stops() +# Resizes the buffer, adjusting its data accordingly. +# @param new_cols The new number of columns. +# @param new_rows The new number of rows. +func resize(new_cols: int, new_rows: int) -> void: + # store reference to null cell with default attrs + var null_cell = get_null_cell(AttributeData.new()) + + # Increase max length if needed before adjustments to allow space to fill + # as required. + var new_max_length = _get_correct_buffer_length(new_rows) + if new_max_length > lines.max_length: + lines.max_length = new_max_length + + # The following adjustments should only happen if the buffer has been + # initialized/filled. + if lines.length > 0: + # Deal with columns increasing (reducing needs to happen after reflow) + if _cols < new_cols: + for i in range(lines.length): + lines.get_line(i).resize(new_cols, null_cell) + + # Resize rows in both directions as needed + var add_to_y = 0 + if _rows < new_rows: + for y in range(_rows, new_rows): + if lines.length < new_rows + ybase: + if _options_service.options.windows_mode: + # Just add the new missing rows on Windows as conpty reprints the screen with it's + # view of the world. Once a line enters scrollback for conpty it remains there + lines.push(BufferLine.new(new_cols, null_cell)) + else: + if ybase > 0 and lines.length <= ybase + y + add_to_y + 1: + # There is room above the buffer and there are no empty elements below the line, + # scroll up + ybase -= 1 + add_to_y += 1 + if ydisp > 0: + # Viewport is at the top of the buffer, must increase downwards + ydisp -= 1 + else: + # Add a blank line if tere is no buffer left at the top to srcoll to, or if there + # are blank lines after the cursor + lines.push(BufferLine.new(new_cols, null_cell)) + else: # _rows >= new_rows + for _y in range(_rows, new_rows, -1): + if lines.length > new_rows + ybase: + if lines.length > ybase + y + 1: + # The line is blank line below the cursor, remove it + lines.pop() + else: + # The line is the cursor, scroll down + ybase += 1 + ydisp += 1 + + # Reduce max length if needed after adjustments, this is done after as it + # would otherwise cut data from the bottom of the buffer. + if new_max_length < lines.max_length: + # Trim from the top of th ebuffer and adjust ybase and ydisp. + var amount_to_trim = lines.length - new_max_length + if amount_to_trim > 0: + lines.trim_start(amount_to_trim) + ybase = max(ybase - amount_to_trim, 0) + ydisp = max(ydisp - amount_to_trim, 0) + saved_y = max(saved_y - amount_to_trim, 0) + lines.max_length = new_max_length + + # Make sure that the cursor stays on screen + x = min(x, new_cols - 1) + y = min(y, new_rows - 1) + if add_to_y: + y += add_to_y + saved_x = min(saved_x, new_cols -1) + + scroll_top = 0 + + scroll_bottom = new_rows - 1 + + if _is_reflow_enabled(): + _reflow(new_cols, new_rows) + + # Trim the end of the line off if cols shrunk + if _cols > new_cols: + for i in range(lines.length): + lines.get_line(i).resize(new_cols, null_cell) + + _cols = new_cols + _rows = new_rows + + +func _is_reflow_enabled() -> bool: + return _has_scrollback and not _options_service.options.windows_mode + + +func _reflow(new_cols: int, new_rows: int) -> void: + if _cols == new_cols: + return + + # Iterate through rows, ignore the last one as it cannot be wrapped + if new_cols > _cols: + _reflow_larger(new_cols, new_rows) + else: + _reflow_smaller(new_cols, new_rows) + + +func _reflow_larger(new_cols: int, new_rows: int) -> void: + var to_remove: PoolIntArray = BufferReflow.reflow_larger_get_lines_to_remove(lines, + _cols, new_cols, ybase + y, get_null_cell(AttributeData.new())) + if not to_remove.empty(): + var new_layout_result = BufferReflow.reflow_larger_create_new_layout(lines, to_remove) + BufferReflow.reflow_larger_apply_new_layout(lines, new_layout_result.layout) + _reflow_larger_adjust_viewport(new_cols, new_rows, new_layout_result.count_removed) + + +func _reflow_larger_adjust_viewport(new_cols: int, new_rows: int, count_removed: int) -> void: + var null_cell = get_null_cell(AttributeData.new()) + # Adjust viewport based on number of items removed + var viewport_adjustments = count_removed + while viewport_adjustments > 0: + viewport_adjustments -= 1 + if ybase == 0: + if y > 0: + y -= 1 + if lines.length < new_rows: + # Add an extra row at the bottom of the viewport + lines.push(BufferLine.new(new_cols, null_cell)) + else: + if ydisp == ybase: + ydisp -= 1 + ybase -= 1 + + saved_y = max(saved_y - count_removed, 0) + + +func _reflow_smaller(new_cols: int, new_rows: int) -> void: + var null_cell = get_null_cell(AttributeData.new()) + # Gather all BufferLines that need to be inserted into the Buffer here so that they can be + # batched up and only commited once + var to_insert = [] + var count_to_insert = 0 + # Go backwards as many lines may be trimmed and this will avoid considering them + var i = lines.length - 1 + while i >= 0: + # Check wether this line is a problem + var next_line = lines.get_line(i) + if not next_line or not next_line.is_wrapped and next_line.get_trimmed_length() <= new_cols: + i -= 1 + continue + + # Gather wrapped lines and adjust y to be the starting line + var wrapped_lines = [next_line] + while next_line.is_wrapped and i > 0: + i -= 1 + next_line = lines.get_line(i) + wrapped_lines.push_front(next_line) + + # If these lines contain the cursor don't touch them, the program will handle fixing up + # wrapped lines with the cursor + var absolute_y = ybase + y + if absolute_y >= i and absolute_y < i + wrapped_lines.size(): + i -= 1 + continue + + var last_line_length = wrapped_lines[wrapped_lines.size() - 1].get_trimmed_length() + var dest_line_lengths = BufferReflow.reflow_smaller_get_new_line_lengths(wrapped_lines, _cols, new_cols) + var lines_to_add = dest_line_lengths.size() - wrapped_lines.size() + var trimmed_lines: int + if ybase == 0 and y != lines.length - 1: + # If the top section of the buffer is not yet filled + trimmed_lines = max(0, y - lines.max_length + lines_to_add) + else: + trimmed_lines = max(0, lines.length - lines.max_length + lines_to_add) + + # Add the new lines + var new_lines = [] + for j in range(lines_to_add): + var new_line = get_blank_line(AttributeData.new(), true) + new_lines.append(new_line) + if not new_lines.empty(): + to_insert.append({"start": i + wrapped_lines.size() + count_to_insert, + "new_lines": new_lines}) + count_to_insert += new_lines.size() + wrapped_lines += new_lines + + # Copy buffer data to new locations, this needs to happen backwards to do in-place + var dest_line_index = dest_line_lengths.size() - 1 # floor(cells_needed / new_cols) + var dest_col = dest_line_lengths[dest_line_index] # cells_needed % new_cols + if dest_col == 0: + dest_line_index -= 1 + dest_col = dest_line_lengths[dest_line_index] + var src_line_index = wrapped_lines.size() - lines_to_add - 1 + var src_col = last_line_length + while src_line_index >= 0: + var cells_to_copy = min(src_col, dest_col) + wrapped_lines[dest_line_index].copy_cells_from(wrapped_lines[src_line_index], + src_col - cells_to_copy, dest_col - cells_to_copy, cells_to_copy, true) + dest_col -= cells_to_copy + if dest_col == 0: + dest_line_index -= 1 + dest_col = dest_line_lengths[dest_line_index] + src_col -= cells_to_copy + if src_col == 0: + src_line_index -= 1 + var wrapped_lines_index = max(src_line_index, 0) + src_col = BufferReflow.get_wrapped_line_trimmed_length(wrapped_lines, wrapped_lines_index, _cols) + + # Null out the end of the line ends if a wide character wrapped to the following line + for j in range(wrapped_lines.size()): + if dest_line_lengths[j] < new_cols: + wrapped_lines[j].set_cell(dest_line_lengths[j], null_cell) + + # Adjust viewport as needed + var viewport_adjustments = lines_to_add - trimmed_lines + while viewport_adjustments > 0: + if ybase == 0: + if y < new_rows - 1: + y += 1 + lines.pop() + else: + ybase += 1 + ydisp += 1 + else: + # Ensure ybase does not exceed its maximum value + if ybase < min(lines.max_length, (lines.length + count_to_insert) - new_rows): + if ybase == ydisp: + ydisp += 1 + ybase += 1 + viewport_adjustments -= 1 + saved_y = min(saved_y + lines_to_add, ybase + new_rows - 1) + i -= 1 + + # Rearrange lines in the buffer if there are any insertions, this is done at the end rather + # than earlier so that it's a single O(n) pass through the buffer, instead of O(n^2) from many + # costly calls to CircularList.splice. + if not to_insert.empty(): + # Record buffer insert events and then play them backwards so that the indexes are + # correct + var insert_events = [] + + # Record original lines so they don't get overridden when we rearrange the list + var original_lines = [] + for i in range(lines.length): + original_lines.append(lines.get_line(i)) + var original_lines_length = lines.length + + var original_line_index = original_lines_length - 1 + var next_to_insert_index = 0 + var next_to_insert = to_insert[next_to_insert_index] + lines.length = min(lines.max_length, lines.length + count_to_insert) + var count_inserted_so_far = 0 + var j = min(lines.max_length - 1, original_lines_length + count_to_insert - 1) + while j >= 0: + if next_to_insert and next_to_insert.start > original_line_index + count_inserted_so_far: + # Insert extra lines here, adjusting i as needed + for next_i in range(next_to_insert.new_lines.size() - 1, -1, -1): + lines.set_line(j, next_to_insert.new_lines[next_i]) + j -= 1 + j += 1 + + # Create insert events for later + insert_events.append({"index": original_line_index + 1, + "amount": next_to_insert.new_lines.size()}) + count_inserted_so_far += next_to_insert.new_lines.size() + next_to_insert_index += 1 + next_to_insert = to_insert[next_to_insert_index] if to_insert.size() > next_to_insert_index else null + else: + lines.set_line(j, original_lines[original_line_index]) + original_line_index -= 1 + j -= 1 + + # Update markers + var insert_count_emitted = 0 + for i in range(insert_events.size() - 1, -1, -1): + insert_events[i].index += insert_count_emitted + lines.emit_signal("inserted", insert_events[i]) + insert_count_emitted += insert_events[i].amount + var amount_to_trim = max(0, original_lines_length + count_to_insert - lines.max_length) + if amount_to_trim > 0: + lines.emit_signal("trimmed", amount_to_trim) + + func get_null_cell(attr = null): if attr: _null_cell.fg = attr.fg @@ -111,21 +391,23 @@ func get_wrapped_range_for_line(y: int) -> Dictionary: return {"first": first, "last": last} +# Setup the tab stops. +# @param i The index to start setting up tab stops from. func setup_tab_stops(i = null) -> void: - if i == null: - return - - if not tabs.get(i): - i = prev_stop(i) + if i != null: + if not tabs.get(i): + i = prev_stop(i) else: tabs = {} i = 0 - + while i < _cols: tabs[i] = true - i += _options_service.options.tab_stop_width + i += max(_options_service.options.tab_stop_width, 1) +# Move the cursor to the previous tab stop from the given position (default is current). +# @param x The position to move the cursor to the previous tab stop. func prev_stop(x: int) -> int: if x == null: x = self.x @@ -135,7 +417,14 @@ func prev_stop(x: int) -> int: return _cols - 1 if x > _cols else 0 if x < 0 else x - - - - +# Move the cursor one tab stop forward from the given position (default is current). +# @param x The position to move the cursor one tab stop forward. +func next_stop(x = null) -> int: + if x == null: + x = self.x + + x += 1 + while not tabs.get(x) and x < _cols: + x += 1 + + return _cols - 1 if x >= _cols else 0 if x < 0 else x diff --git a/addons/godot_xterm/buffer/buffer_line.gd b/addons/godot_xterm/buffer/buffer_line.gd index c124592..44d157b 100644 --- a/addons/godot_xterm/buffer/buffer_line.gd +++ b/addons/godot_xterm/buffer/buffer_line.gd @@ -41,7 +41,10 @@ func get_cell(index: int): func get_width(index: int) -> int: - return _data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT + if (index * CELL_SIZE + Cell.CONTENT) < _data.size(): + return _data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT + else: + return 0 func has_content(index: int) -> int: @@ -221,13 +224,43 @@ func fill(fill_cell_data) -> void: set_cell(i, fill_cell_data) +# alter to a full copy of line +func copy_from(line) -> void: + _data = line._data.duplicate() + length = line.length + _combined = {} + for k in line._combined.keys(): + _combined[k] = line._combined[k] + is_wrapped = line.is_wrapped + + func get_trimmed_length() -> int: - for i in range(length - 1, 0, -1): + for i in range(length - 1, -1, -1): if _data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK: return i + (_data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT) return 0 +func copy_cells_from(src, src_col: int, dest_col: int, length: int, apply_in_reverse: bool) -> void: + var src_data = src._data + + if apply_in_reverse: + for cell in range(length - 1, -1, -1): + for i in range(CELL_SIZE): + _data[(dest_col + cell) * CELL_SIZE + i] = src_data[(src_col + cell) * CELL_SIZE + i] + else: + for cell in range(length): + for i in range(CELL_SIZE): + _data[(dest_col + cell) * CELL_SIZE + i] = src_data[(src_col + cell) * CELL_SIZE + i] + + # Move any combined data over as needed, FIXME: repeat for extended attrs + var src_combined_keys = src._combined.keys() + for i in range(src_combined_keys.size()): + var key = int(src_combined_keys[i]) + if key >= src_col: + _combined[key + src_col + dest_col] = src._combined[key] + + func translate_to_string(trim_right: bool = false, start_col: int = 0, end_col: int = -1) -> String: if end_col == -1: end_col = length @@ -245,3 +278,14 @@ func translate_to_string(trim_right: bool = false, start_col: int = 0, end_col: result += Constants.WHITESPACE_CELL_CHAR start_col += max(content >> Content.WIDTH_SHIFT, 1) # always advance by 1 return result + + +func duplicate(): + # Workaround as per: https://github.com/godotengine/godot/issues/19345#issuecomment-471218401 + var duplicant = load("res://addons/godot_xterm/buffer/buffer_line.gd").new(length) + duplicant._data = _data.duplicate(true) + duplicant._combined = _combined.duplicate(true) + duplicant._extended_attrs = _extended_attrs.duplicate(true) + duplicant.length = length + duplicant.is_wrapped = is_wrapped + return duplicant diff --git a/addons/godot_xterm/buffer/buffer_reflow.gd b/addons/godot_xterm/buffer/buffer_reflow.gd new file mode 100644 index 0000000..d8a3fbd --- /dev/null +++ b/addons/godot_xterm/buffer/buffer_reflow.gd @@ -0,0 +1,198 @@ +# Copyright (c) 2019 The xterm.js authors. All rights reserved. +# Ported to GDScript by the GodotXterm authors. +# License MIT +extends Object + + +# Evaluates and returns indexes to be removed after a reflow larger occurs. Lines will be removed +# when a wrapped line unwraps. +# @param lines The buffer lines. +# @param newCols The columns after resize. +static func reflow_larger_get_lines_to_remove(lines, old_cols: int, new_cols: int, + buffer_absolute_y: int, null_cell) -> PoolIntArray: + # Gather all BufferLines that need to be removed from the Buffer here so that they can be + # batched up and only committed once + var to_remove = PoolIntArray([]) + + var y = 0 + while y < lines.length - 1: + # Check if this row is wrapped + var i = y + i += 1 + var next_line = lines.get_line(i) + if not next_line.is_wrapped: + y += 1 + continue + + # Check how many lines it's wrapped for + var wrapped_lines = [lines.get_line(y)] + while i < lines.length and next_line.is_wrapped: + wrapped_lines.append(next_line) + i += 1 + next_line = lines.get_line(i) + + # If these lines contain the cursor don't touch them, the program will handle fixing up wrapped + # lines with the cursor + if buffer_absolute_y >= y and buffer_absolute_y < i: + y += wrapped_lines.size() - 1 + y += 1 + continue + + # Copy buffer data to new locations + var dest_line_index = 0 + var dest_col = get_wrapped_line_trimmed_length(wrapped_lines, dest_line_index, old_cols) + var src_line_index = 1 + var src_col = 0 + while src_line_index < wrapped_lines.size(): + var src_trimmed_line_length = get_wrapped_line_trimmed_length(wrapped_lines, src_line_index, old_cols) + var src_remaining_cells = src_trimmed_line_length - src_col + var dest_remaining_cells = new_cols - dest_col + var cells_to_copy = min(src_remaining_cells, dest_remaining_cells) + + wrapped_lines[dest_line_index].copy_cells_from(wrapped_lines[src_line_index], src_col, dest_col, cells_to_copy, false) + + dest_col += cells_to_copy + if dest_col == new_cols: + dest_line_index += 1 + dest_col = 0 + + src_col += cells_to_copy + if src_col == src_trimmed_line_length: + src_line_index += 1 + src_col = 0 + + # Make sure the last cell isn't wide, if it is copy it to the current dest + if dest_col == 0 and dest_line_index != 0: + if wrapped_lines[dest_line_index - 1].get_width(new_cols - 1) == 2: + wrapped_lines[dest_line_index].copy_cells_from(wrapped_lines[dest_line_index - 1], new_cols - 1, dest_col, 1, false) + dest_col += 1 + # Null out the end of the last row + wrapped_lines[dest_line_index - 1].set_cell(new_cols - 1, null_cell) + + # Clear out remaining cells or fragments could remain; + var replaced = wrapped_lines[dest_line_index].translate_to_string() + wrapped_lines[dest_line_index].replace_cells(dest_col, new_cols, null_cell) + + # Work backwards and remove any rows at the end that only contain null cells + var count_to_remove = 0 + for i in range(wrapped_lines.size() - 1, 0, -1): + if i > dest_line_index or wrapped_lines[i].get_trimmed_length() == 0: + count_to_remove += 1 + else: + break + + if count_to_remove > 0: + to_remove.append(y + wrapped_lines.size() - count_to_remove) # index + to_remove.append(count_to_remove) + + y += wrapped_lines.size() - 1 + y += 1 + + return to_remove + + +# Creates and return the new layout for lines given an array of indexes to be removed. +# @param lines The buffer lines. +# @param to_remove The indexes to remove. +static func reflow_larger_create_new_layout(lines, to_remove: PoolIntArray): + var layout = PoolIntArray([]) + # First iterate through the list and get the actual indexes to use for rows + var next_to_remove_index = 0 + var next_to_remove_start = to_remove[next_to_remove_index] + var count_removed_so_far = 0 + var i = 0 + while i < lines.length: + if next_to_remove_start == i: + next_to_remove_index += 1 + var count_to_remove = to_remove[next_to_remove_index] + + # Tell markers that there was a deletion + lines.emit_signal("deleted", i - count_removed_so_far, count_to_remove) + + i += count_to_remove - 1 + count_removed_so_far += count_to_remove + next_to_remove_index += 1 + next_to_remove_start = to_remove[next_to_remove_index] if next_to_remove_index < to_remove.size() else null + else: + layout.append(i) + + i += 1 + + return { "layout": layout, "count_removed": count_removed_so_far } + + +# Applies a new layout to the buffer. This essentially does the same as many splice calls but it's +# done all at once in a single iteration through the list since splice is very expensive. +# @param lines The buffer lines. +# @param new_layout The new layout to apply. +static func reflow_larger_apply_new_layout(lines, new_layout: PoolIntArray) -> void: + # Record original lines so they don't get overridden when we rearrange the list + var new_layout_lines = [] + for i in range(new_layout.size()): + new_layout_lines.append(lines.get_line(new_layout[i])) + + # Rearrange the list + for i in range(new_layout_lines.size()): + lines.set_line(i, new_layout_lines[i]) + lines.length = new_layout.size() + + +# Gets the new line lengths for a given wrapped line. The purpose of this function it to pre- +# compute the wrapping points since wide characters may need to be wrapped onto the following line. +# This function will return an array of numbers of where each line wraps to, the resulting array +# will only contain the values `newCols` (when the line does not end with a wide character) and +# `new_cols - 1` (when the line does end with a wide character), except for the last value which +# will contain the remaining items to fill the line. +# +# Calling this with a `new_cols` value of `1` will lock up. +# +# @param wrapped_lines The wrapped lines to evaluate. +# @param old_cols The columns before resize. +# @param new_cols The columns after resize. +static func reflow_smaller_get_new_line_lengths(wrapped_lines: Array, old_cols: int, new_cols: int) -> PoolIntArray: + var new_line_lengths = PoolIntArray([]) + var cells_needed: int + for i in range(wrapped_lines.size()): + cells_needed += get_wrapped_line_trimmed_length(wrapped_lines, i, old_cols) + + # Use src_col and scr_line to find the new wrapping point, use that to get the cells_available and + # lines_needed + var src_col = 0 + var src_line = 0 + var cells_available = 0 + while cells_available < cells_needed: + if cells_needed - cells_available < new_cols: + # Add the final line and exit the loop + new_line_lengths.append(cells_needed - cells_available) + break + + src_col += new_cols + var old_trimmed_length = get_wrapped_line_trimmed_length(wrapped_lines, src_line, old_cols) + if src_col > old_trimmed_length: + src_col -= old_trimmed_length + src_line += 1 + + var ends_with_wide = wrapped_lines[src_line].get_width(src_col - 1) == 2 + if ends_with_wide: + src_col -= 1 + + var line_length = new_cols - 1 if ends_with_wide else new_cols + new_line_lengths.append(line_length) + cells_available += line_length + + return new_line_lengths + + +static func get_wrapped_line_trimmed_length(lines: Array, i: int, cols: int) -> int: + # If this is the last row in the wrapped line, get the actual trimmed length + if i == lines.size() - 1: + return lines[i].get_trimmed_length() + + # Detect whether the following line starts with a wide character and the end of the current line + # is null, if so then we can be pretty sure the null character should be excluded from the line + # length + var ends_in_null = not (lines[i].has_content(cols - 1)) and lines[i].get_width(cols - 1) == 1 + var following_line_starts_with_wide = lines[i + 1].get_width(0) == 2 + if ends_in_null and following_line_starts_with_wide: + return cols - 1 + return cols diff --git a/addons/godot_xterm/buffer/buffer_set.gd b/addons/godot_xterm/buffer/buffer_set.gd index e8f0790..63f0cdf 100644 --- a/addons/godot_xterm/buffer/buffer_set.gd +++ b/addons/godot_xterm/buffer/buffer_set.gd @@ -21,8 +21,7 @@ func _init(options_service, buffer_service): alt = Buffer.new(false, options_service, buffer_service) active = normal - # TODO - #setup_tab_stops() + setup_tab_stops() # Sets the normal Bufer of the BufferSet as its currently active Buffer. @@ -53,3 +52,18 @@ func activate_alt_buffer(fill_attr = null) -> void: alt.y = normal.y active = alt emit_signal("buffer_activated", alt, normal) + + +# Resizes both normal and alt buffers, adjusting their data accordingly. +# @param new_cols The new number of columns. +# @param new_rows The new number of rows. +func resize(new_cols: int, new_rows: int) -> void: + normal.resize(new_cols, new_rows) + alt.resize(new_cols, new_rows) + + +# Setup the tab stops. +# @param i The index to start setting up tab stops from. +func setup_tab_stops(i = null) -> void: + normal.setup_tab_stops(i) + alt.setup_tab_stops(i) diff --git a/addons/godot_xterm/buffer/constants.gd b/addons/godot_xterm/buffer/constants.gd index b5521d3..00fc648 100644 --- a/addons/godot_xterm/buffer/constants.gd +++ b/addons/godot_xterm/buffer/constants.gd @@ -91,3 +91,16 @@ enum UnderlineStyle { DOTTED DASHED } + +enum CursorStyle { + BLOCK + UNDERLINE + BAR +} + +enum BellStyle { + NONE + VISUAL + SOUND + BOTH +} diff --git a/addons/godot_xterm/circular_list.gd b/addons/godot_xterm/circular_list.gd index 81c154f..5f4e71d 100644 --- a/addons/godot_xterm/circular_list.gd +++ b/addons/godot_xterm/circular_list.gd @@ -5,7 +5,8 @@ extends Reference # Represents a circular list; a list with a maximum size that wraps around when push is called, # overriding values at the start of the list. -signal deleted + +signal deleted(index, amount) signal inserted signal trimmed @@ -13,6 +14,7 @@ var _array var _start_index: int var length: int = 0 setget _set_length,_get_length var max_length: int setget _set_max_length,_get_max_length +var is_full: bool setget ,_get_is_full func _set_length(new_length: int): @@ -45,7 +47,12 @@ func _get_max_length(): return max_length -func _init(max_length): +# Ringbuffer is at max length. +func _get_is_full() -> bool: + return length == max_length + + +func _init(max_length = 0): self.max_length = max_length _array = [] _array.resize(max_length) @@ -56,10 +63,23 @@ func get_el(index: int): return _array[_get_cyclic_index(index)] +# Alias for `get_al`. +func get_line(index: int): + return get_el(index) + + func set_el(index: int, value) -> void: _array[_get_cyclic_index(index)] = value +# Alias for `set_el`. +func set_line(index: int, value) -> void: + set_el(index, value) + + +# Pushes a new value onto the list, wrapping around to the start of the array, overriding index 0 +# if the maximum length is reached. +# @param value The value to push onto the list. func push(value) -> void: _array[_get_cyclic_index(length)] = value if length == max_length: @@ -70,6 +90,96 @@ func push(value) -> void: length += 1 -func _get_cyclic_index(index: int) -> int: - return _start_index + index % max_length +# Advance ringbuffer index and return current element for recycling. +# Note: The buffer must be full for this method to work. +# @throws When the buffer is not full. +func recycle(): + if length != max_length: + push_error("Can only recycle when the buffer is full") + _start_index = (_start_index + 1) % max_length + emit_signal("trimmed", 1) + return _array[_get_cyclic_index(length - 1)] + +# Removes and returns the last value on the list. +# @return The popped value. +func pop(): + var last = _array[_get_cyclic_index(length - 1)] + length -= 1 + return last + + +# Deletes and/or inserts items at a particular index (in that order). Unlike +# Array.prototype.splice, this operation does not return the deleted items as a new array in +# order to save creating a new array. Note that this operation may shift all values in the list +# in the worst case. +# @param start The index to delete and/or insert. +# @param deleteCount The number of elements to delete. +# @param items The items to insert. +func splice(start: int, delete_count: int, items: Array = []) -> void: + # Delete items + if delete_count: + for i in range(start, length - delete_count): + _array[_get_cyclic_index(i)] = _array[_get_cyclic_index(i + delete_count)] + length -= delete_count + + # Add items + var i = length - 1 + while i >= start: + _array[_get_cyclic_index(i + items.size())] = _array[_get_cyclic_index(i)] + i -= 1 + for i in range(items.size()): + _array[_get_cyclic_index(start + i)] = items[i] + + # Adjust length as needed + if length + items.size() > max_length: + var count_to_trim = (length + items.size()) - max_length + _start_index += count_to_trim + length = max_length + emit_signal("trimmed", count_to_trim) + else: + length += items.size() + + +# Trims a number of items from the start of the list. +# @param count The number of items to remove. +func trim_start(count: int) -> void: + if count > length: + count = length + _start_index += count + length -= count + emit_signal("trimmed", count) + + +func shift_elements(start: int, count: int, offset: int) -> void: + if count <= 0: + return + if start < 0 or start >= length: + self.push_error("start argument out of range") + if start + offset < 0: + self.push_error("cannot shift elements in list beyond index 0") + + if offset > 0: + for i in range(count - 1, -1, -1): + set_el(start + i + offset, get_el(start + i)) + + var expand_list_by = (start + count + offset) - length + + if expand_list_by > 0: + length += expand_list_by + while length > max_length: + length -= 1 + _start_index += 1 + emit_signal("trimmed", 1) + else: + for i in range(0, count): + set_el(start + i + offset, get_el(start + i)) + + +func _get_cyclic_index(index: int) -> int: + return (_start_index + index) % max_length + + +# Wrapper for `push_error` so we can test for calls to this built-in function. +func push_error(message): + push_error(message) diff --git a/addons/godot_xterm/color_manager.gd b/addons/godot_xterm/color_manager.gd index 6f7cd97..8e2ceb9 100644 --- a/addons/godot_xterm/color_manager.gd +++ b/addons/godot_xterm/color_manager.gd @@ -35,12 +35,12 @@ static func _generate_default_ansi_colors() -> PoolColorArray: var r = v[(i / 36) % 6 | 0] var g = v[(i / 6) % 6 | 0] var b = v[i % 6] - colors.append(Color(r, g, b)) + colors.append(Color("%02x%02x%02x" % [r, g, b])) # Generate greys (232-255) for i in range(0, 24): var c = 8 + i * 10 - colors.append(Color(c, c, c)) + colors.append(Color("%02x%02x%02x" % [c, c, c])) return colors diff --git a/addons/godot_xterm/data/charsets.gd b/addons/godot_xterm/data/charsets.gd index e8d4954..a429cb7 100644 --- a/addons/godot_xterm/data/charsets.gd +++ b/addons/godot_xterm/data/charsets.gd @@ -2,23 +2,255 @@ # Ported to GDScript by the GodotXterm authors. # License MIT extends Reference - - # The character sets supported by the terminal. These enable several languages # to be represented within the terminal with only 8-bit encoding. See ISO 2022 # for a discussion on character sets. Only VT100 character sets are supported. + const CHARSETS = { + # DEC Special Character and Line Drawing Set. + # Reference: http:#vt100.net/docs/vt102-ug/table5-13.html + # A lot of curses apps use this if they see TERM=xterm. + # testing: echo -e "\e(0a\e(B" + # The xterm output sometimes seems to conflict with the + # reference above. xterm seems in line with the reference + # when running vttest however. + # The table below now uses xterm"s output from vttest. + "0": { + "`": "\u25c6", # "◆" + "a": "\u2592", # "▒" + "b": "\u2409", # "␉" (HT) + "c": "\u240c", # "␌" (FF) + "d": "\u240d", # "␍" (CR) + "e": "\u240a", # "␊" (LF) + "f": "\u00b0", # "°" + "g": "\u00b1", # "±" + "h": "\u2424", # "␤" (NL) + "i": "\u240b", # "␋" (VT) + "j": "\u2518", # "┘" + "k": "\u2510", # "┐" + "l": "\u250c", # "┌" + "m": "\u2514", # "└" + "n": "\u253c", # "┼" + "o": "\u23ba", # "⎺" + "p": "\u23bb", # "⎻" + "q": "\u2500", # "─" + "r": "\u23bc", # "⎼" + "s": "\u23bd", # "⎽" + "t": "\u251c", # "├" + "u": "\u2524", # "┤" + "v": "\u2534", # "┴" + "w": "\u252c", # "┬" + "x": "\u2502", # "│" + "y": "\u2264", # "≤" + "z": "\u2265", # "≥" + "{": "\u03c0", # "π" + "|": "\u2260", # "≠" + "}": "\u00a3", # "£" + "~": "\u00b7" # "·" + }, + # British character set # ESC (A - # Reference: http://vt100.net/docs/vt220-rm/table2-5.html - 'A': { - '#': '£' + # Reference: http:#vt100.net/docs/vt220-rm/table2-5.html + "A": { + "#": "£" }, # United States character set # ESC (B - 'B': null, + "B": null, + + # Dutch character set + # ESC (4 + # Reference: http:#vt100.net/docs/vt220-rm/table2-6.html + "4": { + "#": "£", + "@": "¾", + "[": "ij", + "\\": "½", + "]": "|", + "{": "¨", + "|": "f", + "}": "¼", + "~": "´" + }, + + # Finnish character set + # ESC (C or ESC (5 + # Reference: http:#vt100.net/docs/vt220-rm/table2-7.html + "C": { + "[": "Ä", + "\\": "Ö", + "]": "Å", + "^": "Ü", + "`": "é", + "{": "ä", + "|": "ö", + "}": "å", + "~": "ü" + }, + "5": { + "[": "Ä", + "\\": "Ö", + "]": "Å", + "^": "Ü", + "`": "é", + "{": "ä", + "|": "ö", + "}": "å", + "~": "ü" + }, + + # French character set + # ESC (R + # Reference: http:#vt100.net/docs/vt220-rm/table2-8.html + "R": { + "#": "£", + "@": "à", + "[": "°", + "\\": "ç", + "]": "§", + "{": "é", + "|": "ù", + "}": "è", + "~": "¨" + }, + + # French Canadian character set + # ESC (Q + # Reference: http:#vt100.net/docs/vt220-rm/table2-9.html + "Q": { + "@": "à", + "[": "â", + "\\": "ç", + "]": "ê", + "^": "î", + "`": "ô", + "{": "é", + "|": "ù", + "}": "è", + "~": "û" + }, + + # German character set + # ESC (K + # Reference: http:#vt100.net/docs/vt220-rm/table2-10.html + "K": { + "@": "§", + "[": "Ä", + "\\": "Ö", + "]": "Ü", + "{": "ä", + "|": "ö", + "}": "ü", + "~": "ß" + }, + + # Italian character set + # ESC (Y + # Reference: http:#vt100.net/docs/vt220-rm/table2-11.html + "Y": { + "#": "£", + "@": "§", + "[": "°", + "\\": "ç", + "]": "é", + "`": "ù", + "{": "à", + "|": "ò", + "}": "è", + "~": "ì" + }, + + # Norwegian/Danish character set + # ESC (E or ESC (6 + # Reference: http:#vt100.net/docs/vt220-rm/table2-12.html + "E": { + "@": "Ä", + "[": "Æ", + "\\": "Ø", + "]": "Å", + "^": "Ü", + "`": "ä", + "{": "æ", + "|": "ø", + "}": "å", + "~": "ü" + }, + "6": { + "@": "Ä", + "[": "Æ", + "\\": "Ø", + "]": "Å", + "^": "Ü", + "`": "ä", + "{": "æ", + "|": "ø", + "}": "å", + "~": "ü" + }, + + # Spanish character set + # ESC (Z + # Reference: http:#vt100.net/docs/vt220-rm/table2-13.html + "Z": { + "#": "£", + "@": "§", + "[": "¡", + "\\": "Ñ", + "]": "¿", + "{": "°", + "|": "ñ", + "}": "ç" + }, + + # Swedish character set + # ESC (H or ESC (7 + # Reference: http:#vt100.net/docs/vt220-rm/table2-14.html + "H": { + "@": "É", + "[": "Ä", + "\\": "Ö", + "]": "Å", + "^": "Ü", + "`": "é", + "{": "ä", + "|": "ö", + "}": "å", + "~": "ü" + }, + "7": { + "@": "É", + "[": "Ä", + "\\": "Ö", + "]": "Å", + "^": "Ü", + "`": "é", + "{": "ä", + "|": "ö", + "}": "å", + "~": "ü" + }, + + # Swiss character set + # ESC (= + # Reference: http:#vt100.net/docs/vt220-rm/table2-15.html + #/ + "=": { + "#": "ù", + "@": "à", + "[": "é", + "\\": "ç", + "]": "ê", + "^": "î", + "_": "è", + "`": "ô", + "{": "ä", + "|": "ö", + "}": "ü", + "~": "û" + }, } # The default character set, US. -const DEFAULT_CHARSET = CHARSETS['B'] +const DEFAULT_CHARSET = CHARSETS["B"] diff --git a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres index 8036b84..22109c0 100644 --- a/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres +++ b/addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres @@ -3,5 +3,4 @@ [ext_resource path="res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.ttf" type="DynamicFontData" id=1] [resource] -size = 20 font_data = ExtResource( 1 ) diff --git a/addons/godot_xterm/input_handler.gd b/addons/godot_xterm/input_handler.gd index f221ebe..11caaeb 100644 --- a/addons/godot_xterm/input_handler.gd +++ b/addons/godot_xterm/input_handler.gd @@ -3,6 +3,11 @@ # Ported to GDScript by the GodotXterm authors. # License MIT extends Reference +# The terminal's InputHandler, this handles all input from the Parser. +# +# Refer to http://invisible-island.net/xterm/ctlseqs/ctlseqs.html to understand +# each function's header comment. + const Constants = preload("res://addons/godot_xterm/parser/constants.gd") const BufferConstants = preload("res://addons/godot_xterm/buffer/constants.gd") @@ -18,6 +23,9 @@ const C1 = Constants.C1 const FgFlags = BufferConstants.FgFlags const BgFlags = BufferConstants.BgFlags const UnderlineStyle = BufferConstants.UnderlineStyle +const CursorStyle = BufferConstants.CursorStyle +const CHARSETS = Charsets.CHARSETS +const Content = BufferConstants.Content const GLEVEL = {'(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2} const MAX_PARSEBUFFER_LENGTH = 131072 @@ -59,7 +67,7 @@ func _get_buffer(): func _init(buffer_service, core_service, charset_service, options_service, - parser = EscapeSequenceParser.new()): + parser = EscapeSequenceParser.new()): _buffer_service = buffer_service _core_service = core_service _charset_service = charset_service @@ -69,72 +77,100 @@ func _init(buffer_service, core_service, charset_service, options_service, buffer = _buffer_service.buffer _buffer_service.connect("buffer_activated", self, "_set_buffer") - # Print handler _parser.set_print_handler(self, "print") - # Execute handlers - _parser.set_execute_handler(C0.BEL, self, 'bell') - _parser.set_execute_handler(C0.LF, self, 'line_feed') - _parser.set_execute_handler(C0.VT, self, 'line_feed') - _parser.set_execute_handler(C0.FF, self, 'line_feed') - _parser.set_execute_handler(C0.CR, self, 'carriage_return') - _parser.set_execute_handler(C0.BS, self, 'backspace') - _parser.set_execute_handler(C0.HT, self, 'insert_tab'); - _parser.set_execute_handler(C0.SO, self, 'shift_out') - _parser.set_execute_handler(C0.SI, self, 'shift_in') - _parser.set_execute_handler(C1.IND, self, 'index') - _parser.set_execute_handler(C1.NEL, self, 'next_line') - _parser.set_execute_handler(C1.HTS, self, 'tab_set') + # CSI handler + _parser.set_csi_handler({"final": "@"}, self, "insert_chars") + _parser.set_csi_handler({"intermediates": " ", "final": "@"}, self, "scroll_left") + _parser.set_csi_handler({"final": "A"}, self, "cursor_up") + _parser.set_csi_handler({"intermediates": " ", "final": "A"}, self, "scroll_right") + _parser.set_csi_handler({"final": "B"}, self, "cursor_down") + _parser.set_csi_handler({"final": "C"}, self, "cursor_forward") + _parser.set_csi_handler({"final": "D"}, self, "cursor_backward") + _parser.set_csi_handler({"final": "E"}, self, "cursor_nextLine") + _parser.set_csi_handler({"final": "F"}, self, "cursor_precedingLine") + _parser.set_csi_handler({"final": "G"}, self, "cursor_char_absolute") + _parser.set_csi_handler({"final": "H"}, self, "cursor_position") + _parser.set_csi_handler({"final": "I"}, self, "cursor_forward_tab") + _parser.set_csi_handler({"final": "J"}, self, "erase_in_display") + _parser.set_csi_handler({"prefix": "?", "final": "J"}, self, "erase_in_display") + _parser.set_csi_handler({"final": "K"}, self, "erase_in_line") + _parser.set_csi_handler({"prefix": "?", "final": "K"}, self, "erase_in_line") + _parser.set_csi_handler({"final": "L"}, self, "insert_lines") + _parser.set_csi_handler({"final": "M"}, self, "delete_lines") + _parser.set_csi_handler({"final": "P"}, self, "delete_chars") + _parser.set_csi_handler({"final": "S"}, self, "scroll_up") + _parser.set_csi_handler({"final": "T"}, self, "scroll_down") + _parser.set_csi_handler({"final": "X"}, self, "erase_chars") + _parser.set_csi_handler({"final": "Z"}, self, "cursor_backward_tab") + _parser.set_csi_handler({"final": "`"}, self, "char_pos_absolute") + _parser.set_csi_handler({"final": "a"}, self, "h_position_relative") + _parser.set_csi_handler({"final": "b"}, self, "repeat_preceding_character") + _parser.set_csi_handler({"final": "c"}, self, "send_device_attributes_primary") + _parser.set_csi_handler({"prefix": ">", "final": "c"}, self, "send_device_attributes_secondary") + _parser.set_csi_handler({"final": "d"}, self, "line_pos_absolute") + _parser.set_csi_handler({"final": "e"}, self, "v_position_relative") + _parser.set_csi_handler({"final": "f"}, self, "h_v_position") + _parser.set_csi_handler({"final": "g"}, self, "tab_clear") + _parser.set_csi_handler({"final": "h"}, self, "set_mode") + _parser.set_csi_handler({"prefix": "?", "final": "h"}, self, "set_mode_private") + _parser.set_csi_handler({"final": "l"}, self, "reset_mode") + _parser.set_csi_handler({"prefix": "?", "final": "l"}, self, "reset_mode_private") + _parser.set_csi_handler({"final": "m"}, self, "char_attributes") + _parser.set_csi_handler({"final": "n"}, self, "device_status") + _parser.set_csi_handler({"prefix": "?", "final": "n"}, self, "device_status_private") + _parser.set_csi_handler({"intermediates": "!", "final": "p"}, self, "soft_reset") + _parser.set_csi_handler({"intermediates": " ", "final": "q"}, self, "set_cursor_style") + _parser.set_csi_handler({"final": "r"}, self, "set_scroll_region") + _parser.set_csi_handler({"final": "s"}, self, "save_cursor") + _parser.set_csi_handler({"final": "t"}, self, "window_options") + _parser.set_csi_handler({"final": "u"}, self, "restore_cursor") + _parser.set_csi_handler({"intermediates": "\"", "final": "}"}, self, "insert_columns") + _parser.set_csi_handler({"intermediates": "\"", "final": "~"}, self, "delete_columns") - # CSI handlers - _parser.set_csi_handler({'final': '@'}, self, 'insert_chars') - _parser.set_csi_handler({'intermediates': ' ', 'final': '@'}, self, 'scroll_left') - _parser.set_csi_handler({'final': 'A'}, self, 'cursor_up') - _parser.set_csi_handler({'intermediates': ' ', 'final': 'A'}, self, 'scroll_right') - _parser.set_csi_handler({'final': 'B'}, self, 'cursor_down') - _parser.set_csi_handler({'final': 'C'}, self, 'cursor_forward') - _parser.set_csi_handler({'final': 'D'}, self, 'cursor_backward') - _parser.set_csi_handler({'final': 'E'}, self, 'cursor_nextLine') - _parser.set_csi_handler({'final': 'F'}, self, 'cursor_precedingLine') - _parser.set_csi_handler({'final': 'G'}, self, 'cursor_char_absolute') - _parser.set_csi_handler({'final': 'H'}, self, 'cursor_position') - _parser.set_csi_handler({'final': 'I'}, self, 'cursor_forward_tab') - _parser.set_csi_handler({'final': 'J'}, self, 'erase_in_display') - _parser.set_csi_handler({'prefix': '?', 'final': 'J'}, self, 'erase_in_display') - _parser.set_csi_handler({'final': 'K'}, self, 'erase_in_line') - _parser.set_csi_handler({'prefix': '?', 'final': 'K'}, self, 'erase_in_line') - _parser.set_csi_handler({'final': 'L'}, self, 'insert_lines') - _parser.set_csi_handler({'final': 'M'}, self, 'delete_lines') - _parser.set_csi_handler({'final': 'P'}, self, 'delete_chars') - _parser.set_csi_handler({'final': 'S'}, self, 'scroll_up') - _parser.set_csi_handler({'final': 'T'}, self, 'scroll_down') - _parser.set_csi_handler({'final': 'X'}, self, 'erase_chars') - _parser.set_csi_handler({'final': 'Z'}, self, 'cursor_backward_tab') - _parser.set_csi_handler({'final': '`'}, self, 'char_pos_absolute') - _parser.set_csi_handler({'final': 'a'}, self, 'h_position_relative') - _parser.set_csi_handler({'final': 'b'}, self, 'repeat_preceding_character') - _parser.set_csi_handler({'final': 'c'}, self, 'send_device_attributes_primary') - _parser.set_csi_handler({'prefix': '>', 'final': 'c'}, self, 'send_device_attributes_secondary') - _parser.set_csi_handler({'final': 'd'}, self, 'line_pos_absolute') - _parser.set_csi_handler({'final': 'e'}, self, 'v_position_relative') - _parser.set_csi_handler({'final': 'f'}, self, 'h_v_position') - _parser.set_csi_handler({'final': 'g'}, self, 'tab_clear') - _parser.set_csi_handler({'final': 'h'}, self, 'set_mode') - _parser.set_csi_handler({'prefix': '?', 'final': 'h'}, self, 'set_mode_private') - _parser.set_csi_handler({'final': 'l'}, self, 'reset_mode') - _parser.set_csi_handler({'prefix': '?', 'final': 'l'}, self, 'reset_mode_private') - _parser.set_csi_handler({'final': 'm'}, self, 'char_attributes') - _parser.set_csi_handler({'final': 'n'}, self, 'device_status') - _parser.set_csi_handler({'prefix': '?', 'final': 'n'}, self, 'device_status_private') - _parser.set_csi_handler({'intermediates': '!', 'final': 'p'}, self, 'soft_reset') - _parser.set_csi_handler({'intermediates': ' ', 'final': 'q'}, self, 'set_cursor_style') - _parser.set_csi_handler({'final': 'r'}, self, 'set_scroll_region') - _parser.set_csi_handler({'final': 's'}, self, 'save_cursor') - _parser.set_csi_handler({'final': 't'}, self, 'window_options') - _parser.set_csi_handler({'final': 'u'}, self, 'restore_cursor') - _parser.set_csi_handler({'intermediates': '\'', 'final': '}'}, self, 'insert_columns') - _parser.set_csi_handler({'intermediates': '\'', 'final': '~'}, self, 'delete_columns') + # execute handler + _parser.set_execute_handler(C0.BEL, self, "bell") + _parser.set_execute_handler(C0.LF, self, "line_feed") + _parser.set_execute_handler(C0.VT, self, "line_feed") + _parser.set_execute_handler(C0.FF, self, "line_feed") + _parser.set_execute_handler(C0.CR, self, "carriage_return") + _parser.set_execute_handler(C0.BS, self, "backspace") + _parser.set_execute_handler(C0.HT, self, "tab"); + _parser.set_execute_handler(C0.SO, self, "shift_out") + _parser.set_execute_handler(C0.SI, self, "shift_in") + # FIXME: What to do with missing? Old code just added those to print + + _parser.set_execute_handler(C1.IND, self, "index") + _parser.set_execute_handler(C1.NEL, self, "next_line") + _parser.set_execute_handler(C1.HTS, self, "tab_set") + + # ESC handlers + _parser.set_esc_handler({"final": "7"}, self, "save_cursor") + _parser.set_esc_handler({"final": "8"}, self, "restore_cursor") + _parser.set_esc_handler({"final": "D"}, self, "index") + _parser.set_esc_handler({"final": "E"}, self, "next_line") + _parser.set_esc_handler({"final": "H"}, self, "tab_set") + _parser.set_esc_handler({"final": "M"}, self, "reverse_index") + _parser.set_esc_handler({"final": "="}, self, "keypad_application_mode") + _parser.set_esc_handler({"final": ">"}, self, "keypad_numeric_mode") + _parser.set_esc_handler({"final": "c"}, self, "full_reset") + _parser.set_esc_handler({"final": "n"}, self, "set_glevel", 2) + _parser.set_esc_handler({"final": "o"}, self, "set_glevel", 3) + _parser.set_esc_handler({"final": "|"}, self, "set_glevel", 3) + _parser.set_esc_handler({"final": "}"}, self, "set_glevel", 2) + _parser.set_esc_handler({"final": "~"}, self, "set_glevel", 1) + _parser.set_esc_handler({"intermediates": "%", "final": "@"}, self, "select_default_charset") + _parser.set_esc_handler({"intermediates": "%", "final": "G"}, self, "select_default_charset") + for flag in CHARSETS.keys(): + _parser.set_esc_handler({"intermediates": "(", "final": flag}, self, "select_charset", "(" + flag) + _parser.set_esc_handler({"intermediates": ")", "final": flag}, self, "select_charset", ")" + flag) + _parser.set_esc_handler({"intermediates": "*", "final": flag}, self, "select_charset", "*" + flag) + _parser.set_esc_handler({"intermediates": "+", "final": flag}, self, "select_charset", "+" + flag) + _parser.set_esc_handler({"intermediates": "-", "final": flag}, self, "select_charset", "-" + flag) + _parser.set_esc_handler({"intermediates": ".", "final": flag}, self, "select_charset", "." + flag) + _parser.set_esc_handler({"intermediates": "/", "final": flag}, self, "select_charset", "/" + flag) # TODO: supported? + _parser.set_esc_handler({"intermediates": "#", "final": "8"}, self, "screen_alignment_pattern") #func parse(data) -> void: @@ -224,7 +260,7 @@ func print(data: Array, start: int, end: int) -> void: # charset is only defined for ASCII, therefore we only # search for an replacement char if code < 127 if code < 127 and charset: - var ch = charset[char(code)] + var ch = charset.get(char(code)) if ch: code = ch.ord_at(0) @@ -334,7 +370,7 @@ func line_feed(): buffer.y += 1 if buffer.y == buffer.scroll_bottom + 1: buffer.y -= 1 - emit_signal("scroll_requested") + emit_signal("scroll_requested", _erase_attr_data()) elif buffer.y >= _buffer_service.rows: buffer.y = _buffer_service.rows - 1 # If the end of the line is hit, prevent this action from wrapping around to the next line. @@ -400,12 +436,22 @@ func tab(): # TODO A11y +# SO +# Shift Out (Ctrl-N) -> Switch to Alternate Character Set. This invokes the +# G1 character set. +# +# @vt: #P[Only limited ISO-2022 charset support.] C0 SO "Shift Out" "\x0E" "Switch to an alternative character set." func shift_out(): _charset_service.set_glevel(1) +# SI +# Shift In (Ctrl-O) -> Switch to Standard Character Set. This invokes the G0 +# character set (the default). +# +# @vt: #Y C0 SI "Shift In" "\x0F" "Return to regular character set after Shift Out." func shift_in(): - _charset_service.get_glevel(0) + _charset_service.set_glevel(0) func _restrict_cursor(max_col: int = _buffer_service.cols - 1) -> void: @@ -439,20 +485,142 @@ func _move_cursor(x: int, y: int) -> void: _set_cursor(self._buffer.y + x, self._buffer.y + y) -func index(): - print("TODO: index") +# ESC D +# C1.IND +# DEC mnemonic: IND (https://vt100.net/docs/vt510-rm/IND.html) +# Moves the cursor down one line in the same column. +# +# @vt: #Y C1 IND "Index" "\x84" "Move the cursor one line down scrolling if needed." +# @vt: #Y ESC IND "Index" "ESC D" "Move the cursor one line down scrolling if needed." +func index() -> void: + _restrict_cursor() + buffer.y += 1 + if buffer.y == buffer.scroll_bottom + 1: + buffer.y -= 1 + emit_signal("scroll_requested", _erase_attr_data()) + elif buffer.y >= _buffer_service.rows: + buffer.y = _buffer_service.rows - 1 + _restrict_cursor() -func next_line(): - print("TODO: next_line") +# ESC E +# C1.NEL +# DEC mnemonic: NEL (https://vt100.net/docs/vt510-rm/NEL) +# Moves cursor to first position on next line. +# +# @vt: #Y C1 NEL "Next Line" "\x85" "Move the cursor to the beginning of the next row." +# @vt: #Y ESC NEL "Next Line" "ESC E" "Move the cursor to the beginning of the next row." +func next_line() -> void: + buffer.x = 0 + index() + + +# ESC = +# DEC mnemonic: DECKPAM (https://vt100.net/docs/vt510-rm/DECKPAM.html) +# Enables the numeric keypad to send application sequences to the host. +func keypad_application_mode() -> void: + _core_service.dec_private_modes.application_keypad = true + emit_signal("scrollbar_sync_requested") + + +# ESC > +# DEC mnemonic: DECKPNM (https://vt100.net/docs/vt510-rm/DECKPNM.html) +# Enables the keypad to send numeric characters to the host. +func keypad_numeric_mode() -> void: + _core_service.dec_private_modes.application_keypad = false + emit_signal("scrollbar_sync_requested") + + +# ESC % @ +# ESC % G +# Select default character set. UTF-8 is not supported (string are unicode anyways) +# therefore ESC % G does the same. +func select_default_charset() -> void: + _charset_service.set_glevel(0) + _charset_service.set_gcharset(0, Charsets.DEFAULT_CHARSET) # US (default) + + +# ESC ( C +# Designate G0 Character Set, VT100, ISO 2022. +# ESC ) C +# Designate G1 Character Set (ISO 2022, VT100). +# ESC * C +# Designate G2 Character Set (ISO 2022, VT220). +# ESC + C +# Designate G3 Character Set (ISO 2022, VT220). +# ESC - C +# Designate G1 Character Set (VT300). +# ESC . C +# Designate G2 Character Set (VT300). +# ESC / C +# Designate G3 Character Set (VT300). C = A -> ISO Latin-1 Supplemental. - Supported? +func select_charset(collect_and_flag: String) -> void: + if collect_and_flag.length() != 2: + select_default_charset() + return + + if collect_and_flag.substr(0, 1) == "/": + return # TODO: Is this supported? + + var g = GLEVEL[collect_and_flag.substr(0, 1)] + var charset = CHARSETS[collect_and_flag.substr(1, 1)] + _charset_service.set_gcharset(g, charset) + + +# ESC H +# C1.HTS +# DEC mnemonic: HTS (https://vt100.net/docs/vt510-rm/HTS.html) +# Sets a horizontal tab stop at the column position indicated by +# the value of the active column when the terminal receives an HTS. +# +# @vt: #Y C1 HTS "Horizontal Tabulation Set" "\x88" "Places a tab stop at the current cursor position." +# @vt: #Y ESC HTS "Horizontal Tabulation Set" "ESC H" "Places a tab stop at the current cursor position." func tab_set(): - print("TODO: tab_set") + buffer.tabs[buffer.x] = true -func insert_chars(params): - print("TODO: insert_chars") -func scroll_left(params): - print("TODO: scroll_left") +# CSI Ps @ +# Insert Ps (Blank) Character(s) (default = 1) (ICH). +# +# @vt: #Y CSI ICH "Insert Characters" "CSI Ps @" "Insert `Ps` (blank) characters (default = 1)." +# The ICH sequence inserts `Ps` blank characters. The cursor remains at the beginning of the blank characters. +# Text between the cursor and right margin moves to the right. Characters moved past the right margin are lost. +# +# +# FIXME: check against xterm - should not work outside of scroll margins (see VT520 manual) +func insert_chars(params) -> void: + _restrict_cursor() + var line = buffer.lines.get_el(buffer.ybase + buffer.y) + if line: + line.insert_cells(buffer.x, params.get_param(0, 1), + buffer.get_null_cell(_erase_attr_data()), _erase_attr_data()) + + +# CSI Ps SP @ Scroll left Ps columns (default = 1) (SL) ECMA-48 +# +# Notation: (Pn) +# Representation: CSI Pn 02/00 04/00 +# Parameter default value: Pn = 1 +# SL causes the data in the presentation component to be moved by n character positions +# if the line orientation is horizontal, or by n line positions if the line orientation +# is vertical, such that the data appear to move to the left; where n equals the value of Pn. +# The active presentation position is not affected by this control function. +# +# Supported: +# - always left shift (no line orientation setting respected) +# +# @vt: #Y CSI SL "Scroll Left" "CSI Ps SP @" "Scroll viewport `Ps` times to the left." +# SL moves the content of all lines within the scroll margins `Ps` times to the left. +# SL has no effect outside of the scroll margins. +func scroll_left(params) -> void: + if buffer.y > buffer.scroll_bottom or buffer.y < buffer.scroll_top: + return + var param = params.get_param(0, 1) + for y in range(buffer.scroll_top, buffer.scroll_bottom + 1): + var line = buffer.lines.get_el(buffer.ybase + y) + line.delete_cells(0, param, buffer.get_null_cell(_erase_attr_data()), + _erase_attr_data()) + line.is_wrapped = false func cursor_up(params) -> void: @@ -464,15 +632,39 @@ func cursor_up(params) -> void: _move_cursor(0, -params.get_param(0, 1)) -func scroll_right(params): - print("TODO: scroll_right") +# CSI Ps SP A Scroll right Ps columns (default = 1) (SR) ECMA-48 +# +# Notation: (Pn) +# Representation: CSI Pn 02/00 04/01 +# Parameter default value: Pn = 1 +# SR causes the data in the presentation component to be moved by n character positions +# if the line orientation is horizontal, or by n line positions if the line orientation +# is vertical, such that the data appear to move to the right; where n equals the value of Pn. +# The active presentation position is not affected by this control function. +# +# Supported: +# - always right shift (no line orientation setting respected) +# +# @vt: #Y CSI SR "Scroll Right" "CSI Ps SP A" "Scroll viewport `Ps` times to the right." +# SL moves the content of all lines within the scroll margins `Ps` times to the right. +# Content at the right margin is lost. +# SL has no effect outside of the scroll margins. +func scroll_right(params) -> void: + if buffer.y > buffer.scroll_bottom or buffer.y < buffer.scroll_top: + return + var param = params.get_param(0, 1) + for y in range(buffer.scroll_top, buffer.scroll_bottom + 1): + var line = buffer.lines.get_el(buffer.ybase + y) + line.insert_cells(0, param, buffer.get_null_cell(_erase_attr_data()), + _erase_attr_data()) + line.is_wrapped = false func cursor_down(params): # stop at scroll_bottom var diff_to_bottom = self._buffer.scroll_bottom - self._buffer.y if diff_to_bottom >= 0: - _move_cursor(0, min(diff_to_bottom, params[0] if params[0] else 1)) + _move_cursor(0, min(diff_to_bottom, params.get_param(0,1))) else: _move_cursor(0, params.get_param(0, 1)) @@ -502,18 +694,18 @@ func cursor_char_absolute(params): func cursor_position(params): _set_cursor( # col - (params.get_param(1, 1)) - 1 if params.size() >= 2 else 0, + (params.get_param(1, 1)) - 1 if params.length >= 2 else 0, # row (params.get_param(0, 1)) - 1 ) func char_pos_absolute(params) -> void: - _set_cursor((params[0] if params[0] else 1) - 1, self._buffer.y) + _set_cursor(params.get_param(0, 1) - 1, self._buffer.y) func h_position_relative(params): - _move_cursor(params[0] if params[0] else 1, 0) + _move_cursor(params.get_param(0, 1), 0) func line_pos_absolute(params): @@ -521,12 +713,13 @@ func line_pos_absolute(params): func v_position_relative(params): - _move_cursor(0, params[0] if params[0] else 1) + _move_cursor(0, params.get_param(0, 1)) func h_v_position(params): cursor_position(params) + # CSI Ps g Tab Clear (TBC). # Ps = 0 -> Clear Current Column (default). # Ps = 3 -> Clear All. @@ -537,7 +730,7 @@ func h_v_position(params): # @vt: #Y CSI TBC "Tab Clear" "CSI Ps g" "Clear tab stops at current position (0) or all (3) (default=0)." # Clearing tabstops off the active row (Ps = 2, VT100) is currently not supported. func tab_clear(params) -> void: - match params[0]: + match params.get_param(0): 3: self._buffer.tabs = {} 0, _: @@ -551,7 +744,7 @@ func tab_clear(params) -> void: func cursor_forward_tab(params) -> void: if self._buffer.x >= self._buffer.cols: return - var param = params[0] if params[0] else 1 + var param = params.get_param(0, 1) while param: self._buffer.x = self._buffer.next_stop() param -= 1 @@ -560,7 +753,7 @@ func cursor_forward_tab(params) -> void: func cursor_backward_tab(params) -> void: if self._buffer.x >= _buffer_service.cols: return - var param = params[0] if params[0] else 1 + var param = params.get_param(0, 1) while param: self._buffer.x = self._buffer.buffer.prev_stop() param -= 1 @@ -582,10 +775,10 @@ func _erase_in_buffer_line(y: int, start: int, end: int, clear_wrap: bool = fals # Helper method to reset cells in a terminal row. # The cell gets replaced with the eraseChar of the terminal and the isWrapped property is set to false. -# `y` is the row index +# @param y row index func _reset_buffer_line(y: int) -> void: - var line = self._buffer.lines.get_el(self._buffer.ybase + y) - line.fill(self._buffer.get_null_cell(_erase_attr_data())) + var line = buffer.lines.get_line(buffer.ybase + y) + line.fill(buffer.get_null_cell(_erase_attr_data())) line.is_wrapped = false @@ -594,33 +787,32 @@ func erase_in_display(params) -> void: var j match params.get_param(0): 0: - j = self._buffer.y + j = buffer.y # _dirty_row_service.mark_dirty(j) - _erase_in_buffer_line(j, self._buffer.x, _buffer_service.cols, self._buffer.x == 0) + _erase_in_buffer_line(j, buffer.x, _buffer_service.cols, buffer.x == 0) j += 1 while j < _buffer_service.rows: _reset_buffer_line(j) j += 1 # _dirty_row_service.mark_dirty(j) 1: - j = self._buffer.y + j = buffer.y # _dirty_row_service.mark_dirty(j) # Deleted front part of line and everything before. This line will no longer be wrapped. - _erase_in_buffer_line(j, 0, self._buffer.x + 1, true) - if self._buffer.x + 1 >= _buffer_service.cols: + _erase_in_buffer_line(j, 0, buffer.x + 1, true) + if buffer.x + 1 >= _buffer_service.cols: # Deleted entire previous line. This next line can no longer be wrapped. - self._buffer.lines.get_el(j + 1).is_wrapped = false - j -= 1 - while j >= 0: - _reset_buffer_line(j) + buffer.lines.get_el(j + 1).is_wrapped = false + while j > 0: j -= 1 + _reset_buffer_line(j) # _dirty_row_service.mark_dirty(0) 2: j = _buffer_service.rows # _dirty_row_service.mark_dirty(j - 1) - while j: - _reset_buffer_line(j) + while j > 0: j -= 1 + _reset_buffer_line(j) # _dirty_row_sevice.mark_dirty(0) 3: # Clear scrollback (everything not in viewport) @@ -644,12 +836,71 @@ func erase_in_line(params): _erase_in_buffer_line(buffer.y, 0, _buffer_service.cols) -func insert_lines(params): - print("TODO: insert_lines") -func delete_lines(params): - print("TODO: delete_lines") +# CSI Ps L +# Insert Ps Line(s) (default = 1) (IL). +# +# @vt: #Y CSI IL "Insert Line" "CSI Ps L" "Insert `Ps` blank lines at active row (default=1)." +# For every inserted line at the scroll top one line at the scroll bottom gets removed. +# The cursor is set to the first column. +# IL has no effect if the cursor is outside the scroll margins. +func insert_lines(params) -> void: + _restrict_cursor() + var param = params.get_param(0, 1) + + if buffer.y > buffer.scroll_bottom or buffer.y < buffer.scroll_top: + return + + var row: int = buffer.ybase + buffer.y + var scroll_bottom_row_offset = _buffer_service.rows - 1 - buffer.scroll_bottom + var scroll_bottom_absolute = _buffer_service.rows - 1 + buffer.ybase - scroll_bottom_row_offset + 1 + + while param: + # test: echo -e '\e[44m\e[1L\e[0m' + # blank_line(true) - xterm/linux behavior + buffer.lines.splice(scroll_bottom_absolute - 1, 1) + buffer.lines.splice(row, 0, [buffer.get_blank_line(_erase_attr_data())]) + param -= 1 + + buffer.x = 0 # see https://vt100.net/docs/vt220-rm/chapter4.html - vt220 only? +# CSI Ps M +# Delete Ps Line(s) (default = 1) (DL). +# +# @vt: #Y CSI DL "Delete Line" "CSI Ps M" "Delete `Ps` lines at active row (default=1)." +# For every deleted line at the scroll top one blank line at the scroll bottom gets appended. +# The cursor is set to the first column. +# DL has no effect if the cursor is outside the scroll margins. +func delete_lines(params) -> void: + _restrict_cursor() + var param = params.get_param(0, 1) + + if buffer.y > buffer.scroll_bottom or buffer.y < buffer.scroll_top: + return + + var row: int = buffer.ybase + buffer.y + + var j: int + j = _buffer_service.rows - 1 - buffer.scroll_bottom + j = _buffer_service.rows - 1 + buffer.ybase - j + + while param: + # test echo -e '\e[44m\e[1M\e[0m' + # blank_line(true) - xterm/linux behavior + buffer.lines.splice(row, 1) + buffer.lines.splice(j, 0, [buffer.get_blank_line(_erase_attr_data())]) + param -= 1 + + +# CSI Ps P +# Delete Ps Character(s) (default = 1) (DCH). +# +# @vt: #Y CSI DCH "Delete Character" "CSI Ps P" "Delete `Ps` characters (default=1)." +# As characters are deleted, the remaining characters between the cursor and right margin move to the left. +# Character attributes move with the characters. The terminal adds blank characters at the right margin. +# +# +# FIXME: check against xterm - should not work outside of scroll margins (see VT520 manual) func delete_chars(params) -> void: _restrict_cursor() var line = buffer.lines.get_el(buffer.ybase + buffer.y) @@ -659,10 +910,31 @@ func delete_chars(params) -> void: #_dirty_row_service.markDirty(buffer.y) -func scroll_up(params): - print("TODO: scroll_up") -func scroll_down(params): - print("TODO: scroll_down") +# CSI Ps S Scroll up Ps lines (default = 1) (SU). +# +# @vt: #Y CSI SU "Scroll Up" "CSI Ps S" "Scroll `Ps` lines up (default=1)." +# +# +# FIXME: scrolled out lines at top = 1 should add to scrollback (xterm) +func scroll_up(params) -> void: + var param = params.get_param(0, 1) + while param: + buffer.lines.splice(buffer.ybase + buffer.scroll_top, 1) + buffer.lines.splice(buffer.ybase + buffer.scroll_bottom, 0, + [buffer.get_blank_line(_erase_attr_data())]) + param -= 1 + + +# CSI Ps T Scroll down Ps lines (default = 1) (SD). +# +# @vt: #Y CSI SD "Scroll Down" "CSI Ps T" "Scroll `Ps` lines down (default=1)." +func scroll_down(params) -> void: + var param = params.get_param(0, 1) + while param: + buffer.lines.splice(buffer.ybase + buffer.scroll_bottom, 1) + buffer.lines.splice(buffer.ybase + buffer.scroll_top, 0, + buffer.get_blank_line(DEFAULT_ATTRIBUTE_DATA)) + param -= 1 func erase_chars(params) -> void: @@ -686,15 +958,19 @@ func repeat_preceding_character(params) -> void: func send_device_attributes_primary(params): - print("TODO: send dev attr primary") + # TODO + pass + + func send_device_attributes_secondary(params): - print("TODO: send dev attr second") - - + # TODO + pass func set_mode(params): - print("TODO: set mode") + # TODO + pass + func reset_mode(params) -> void: for param in params.params: @@ -708,14 +984,15 @@ func reset_mode(params) -> void: func char_attributes(params): # Optimize a single SGR0 - if params.size() == 1 and params[0] == 0: - _cur_attr_data.fg = DEFAULT_ATTRIBUTE_DATA.fg - _cur_attr_data.bg = DEFAULT_ATTRIBUTE_DATA.bg + if params.length == 1 and params.get_param(0) == 0: + _cur_attr_data.fg = AttributeData.new().fg + _cur_attr_data.bg = AttributeData.new().bg return var attr = _cur_attr_data - for p in params.to_array(): + for i in range(params.length): + var p = params.get_param(i) if p >= 30 and p <= 37: # fg color 8 attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK) @@ -745,17 +1022,140 @@ func char_attributes(params): elif p == 4: # underlined text attr.fg |= FgFlags.UNDERLINE - _process_underline(p.get_sub_params()[0] if p.has_sub_params() else UnderlineStyle.SINGLE, attr) + _process_underline(params.get_sub_params(i)[0] if params.has_sub_params(i) else UnderlineStyle.SINGLE, attr) + elif p == 5: + # blink + # test with: echo -e '\e[5mblink\e[m' + attr.fg |= FgFlags.BLINK + elif p == 7: + # inverse and positive + # test with: echo -e '\e[31m\e[42mhello\e[7mworld\e[27mhi\e[m' + attr.fg |= FgFlags.INVERSE + elif p == 8: + # invisible + attr.fg |= FgFlags.INVISIBLE + elif p == 2: + # dimmed text + attr.bg |= BgFlags.DIM + elif p == 21: + # double underline + _process_underline(UnderlineStyle.DOUBLE, attr) + elif p == 22: + # not bold nor faint + attr.fg &= ~FgFlags.BOLD + attr.bg &= ~BgFlags.DIM + elif p == 23: + # not italic + attr.bg &= ~BgFlags.ITALIC + elif p == 24: + # not underlined + attr.fg &= ~FgFlags.UNDERLINE + elif p == 25: + # not blink + attr.fg &= ~FgFlags.BLINK + elif p == 27: + # not inverse + attr.fg &= ~FgFlags.INVERSE + elif p == 28: + # not invisible + attr.fg &= ~FgFlags.INVISIBLE + elif p == 39: + # reset fg + attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK) + attr.fg |= AttributeData.new().fg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK) + elif p == 49: + # reset bg + attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK) + attr.bg |= AttributeData.new().bg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK) + elif p == 38 or p == 48 or p == 58: + # fg color 256 and RGB + i += _extract_color(params, i, attr) + elif p == 59: + attr.extended = attr.extended.duplicate() + attr.extended.underline_color = -1 + attr.update_extended() + elif p == 100: # FIXME: dead branch, p=100 already handled above! + # TODO reset fg/bg + pass func device_status(params): - print("TODO: dev stat") + # TODO + pass + + func device_status_private(params): - print("TODO: dev stat priv") + # TODO + pass + + +# CSI ! p Soft terminal reset (DECSTR). +# http://vt100.net/docs/vt220-rm/table4-10.html +# +# @vt: #Y CSI DECSTR "Soft Terminal Reset" "CSI ! p" "Reset several terminal attributes to initial state." +# There are two terminal reset sequences - RIS and DECSTR. While RIS performs almost a full terminal bootstrap, +# DECSTR only resets certain attributes. For most needs DECSTR should be sufficient. +# +# The following terminal attributes are reset to default values: +# - IRM is reset (dafault = false) +# - scroll margins are reset (default = viewport size) +# - erase attributes are reset to default +# - charsets are reset +# - DECSC data is reset to initial values +# - DECOM is reset to absolute mode +# +# +# FIXME: there are several more attributes missing (see VT520 manual) func soft_reset(params): - print("TODO: soft reset") -func set_cursor_style(params): - print("TODO: set cur styl") + _core_service.is_cursor_hidden = false + emit_signal("scrollbar_sync_requested") + buffer.scroll_top = 0 + buffer.scroll_bottom = _buffer_service.rows - 1 + _cur_attr_data = DEFAULT_ATTRIBUTE_DATA + _core_service.reset() + _charset_service.reset() + + # reset DECSC data + buffer.saved_x = 0 + buffer.saved_y = buffer.ybase + buffer.saved_cur_attr_data.fg = _cur_attr_data.fg + buffer.saved_cur_attr_data.bg = _cur_attr_data.bg + buffer.saved_charset = _charset_service.charset + + # reset DECOM + _core_service.dec_private_modes.origin = false + + +# CSI Ps SP q Set cursor style (DECSCUSR, VT520). +# Ps = 0 -> blinking block. +# Ps = 1 -> blinking block (default). +# Ps = 2 -> steady block. +# Ps = 3 -> blinking underline. +# Ps = 4 -> steady underline. +# Ps = 5 -> blinking bar (xterm). +# Ps = 6 -> steady bar (xterm). +# +# @vt: #Y CSI DECSCUSR "Set Cursor Style" "CSI Ps SP q" "Set cursor style." +# Supported cursor styles: +# - empty, 0 or 1: steady block +# - 2: blink block +# - 3: steady underline +# - 4: blink underline +# - 5: steady bar +# - 6: blink bar +func set_cursor_style(params) -> void: + var param = params.get_param(0, 1) + + match param: + 1, 2: + _options_service.options.cursor_style = CursorStyle.BLOCK + 3, 4: + _options_service.options.cursor_style = CursorStyle.UNDERLINE + 5, 6: + _options_service.options.cursor_style = CursorStyle.BAR + + var is_blinking = param % 2 == 1 + _options_service.options.cursor_blink = is_blinking func set_scroll_region(params) -> void: @@ -794,11 +1194,44 @@ func window_options(params): pass - +# CSI Pm ' } +# Insert Ps Column(s) (default = 1) (DECIC), VT420 and up. +# +# @vt: #Y CSI DECIC "Insert Columns" "CSI Ps ' }" "Insert `Ps` columns at cursor position." +# DECIC inserts `Ps` times blank columns at the cursor position for all lines with the scroll margins, +# moving content to the right. Content at the right margin is lost. +# DECIC has no effect outside the scrolling margins. func insert_columns(params): - print("TODO: insert_columns") + if buffer.y > buffer.scroll_bottom or buffer.y < buffer.scroll_top: + return + + var param = params.get_param(0, 1) + + for y in range(buffer.scroll_top, buffer.scroll_bottom + 1): + var line = buffer.lines.get_el(buffer.ybase + y) + line.insert_cells(buffer.x, param, buffer.get_null_cells(_erase_attr_data()), + _erase_attr_data()) + line.is_wrapped = false + + +# CSI Pm ' ~ +# Delete Ps Column(s) (default = 1) (DECDC), VT420 and up. +# +# @vt: #Y CSI DECDC "Delete Columns" "CSI Ps ' ~" "Delete `Ps` columns at cursor position." +# DECDC deletes `Ps` times columns at the cursor position for all lines with the scroll margins, +# moving content to the left. Blank columns are added at the right margin. +# DECDC has no effect outside the scrolling margins. func delete_columns(params): - print("TODO: delete_cols") + if buffer.y > buffer.scroll_bottom or buffer.y < buffer.scroll_top: + return + + var param = params.get_param(0, 1) + + for y in range(buffer.scroll_top, buffer.scroll_bottom + 1): + var line = buffer.lines.get_el(buffer.ybase + y) + line.delete_cells(buffer.x, param, buffer.get_null_cells(_erase_attr_data()), + _erase_attr_data()) + line.is_wrapped = false func set_mode_private(params) -> void: @@ -870,7 +1303,6 @@ func set_mode_private(params) -> void: _core_service.dec_private_modes.bracketed_paste_mode = true - func reset_mode_private(params): for param in params.to_array(): match param: @@ -942,7 +1374,7 @@ func _update_attr_color(color: int, mode: int, c1: int, c2: int, c3: int) -> int # Helper to extract and apply color params/subparams. # Returns advance for params index. -func _extract_color(params: Array, pos: int, attr) -> int: +func _extract_color(params, pos: int, attr) -> int: # normalize params # meaning: [target, CM, ign, val, val, val] # RGB : [ 38/34, 2, ign, r, g, b] @@ -955,8 +1387,8 @@ func _extract_color(params: Array, pos: int, attr) -> int: # return advance we took in params var advance = -1 - while advance + pos < params.size() and advance + c_space < accu.size(): - accu[advance + c_space] = params[pos + advance] + while advance + pos < params.length and advance + c_space < accu.size(): + accu[advance + c_space] = params.get_param(pos + advance) advance += 1 # TODO FIX and FINISH me return advance @@ -983,17 +1415,16 @@ func restore_cursor(params = null) -> void: # func reverse_index() -> void: _restrict_cursor() -# if self._buffer.y == self._buffer.scroll_top: -# # possibly move the code below to term.reverse_scroll() -# # test: echo -ne '\e[1;1H\e[44m\eM\e[0m' -# # blankLine(true) is xterm/linux behavior -# var scroll_region_height = self._buffer.scroll_bottom - self._buffer.scroll_top -# self._buffer.lines.shiftElements(buffer.ybase + buffer.y, scrollRegionHeight, 1); -# buffer.lines.set(buffer.ybase + buffer.y, buffer.getBlankLine(this._eraseAttrData())); -# this._dirtyRowService.markRangeDirty(buffer.scrollTop, buffer.scrollBottom); -# else -# self._buffer.y -= 1 -# _restrict_cursor() # quickfix to not run out of bounds + if buffer.y == buffer.scroll_top: + # possibly move the code below to term.reverse_srcoll() + # test: echo -ne '\e[1;1H\e[44m\eM\e[0m' + # blank_line(true) is xterm/linux behavior + var scroll_region_height = buffer.scroll_bottom - buffer.scroll_top + buffer.lines.shift_elements(buffer.ybase + buffer.y, scroll_region_height, 1) + buffer.lines.set_line(buffer.ybase + buffer.y, buffer.get_blank_line(_erase_attr_data())) + else: + buffer.y -= 1 + _restrict_cursor() # quickfix to not run out of bounds # ESC c @@ -1012,7 +1443,7 @@ func reset() -> void: func _process_underline(style: int, attr) -> void: # treat extended attrs as immutable, thus always clone from old one # this is needed since the buffer only holds references to it - attr.extended = attr.extended.duplicate() # BEWARE. Maybe don't do this! + attr.extended = attr.extended.duplicate() # default to 1 == single underline if not ~style or style > 5: @@ -1034,3 +1465,27 @@ func _erase_attr_data(): _erase_attr_data_internal.bg &= ~(Attributes.CM_MASK | 0xFFFFFF) _erase_attr_data_internal.bg |= _cur_attr_data.bg & ~0xFC000000 return _erase_attr_data_internal + + +# ESC # 8 +# DEC mnemonic: DECALN (https://vt100.net/docs/vt510-rm/DECALN.html) +# This control function fills the complete screen area with +# a test pattern (E) used for adjusting screen alignment. +# +# @vt: #Y ESC DECALN "Screen Alignment Pattern" "ESC # 8" "Fill viewport with a test pattern (E)." +func screen_alignment_pattern() -> void: + # prepare cell data + var cell = CellData.new() + cell.content = 1 << Content.WIDTH_SHIFT | 'E'.ord_at(0) + cell.fg = _cur_attr_data.fg + cell.bg = _cur_attr_data.bg + + _set_cursor(0, 0) + + for y_offset in range(0, _buffer_service.rows): + var row = buffer.ybase + buffer.y + y_offset + var line = buffer.lines.get_line(row) + if line: + line.fill(cell) + line.is_wrapped = false + _set_cursor(0, 0) diff --git a/addons/godot_xterm/parser/escape_sequence_parser.gd b/addons/godot_xterm/parser/escape_sequence_parser.gd index e99bb91..ee5c155 100644 --- a/addons/godot_xterm/parser/escape_sequence_parser.gd +++ b/addons/godot_xterm/parser/escape_sequence_parser.gd @@ -145,8 +145,9 @@ func set_execute_handler_fallback(target: Object, method: String): _execute_handler_fb = { 'target': target, 'method': method } -func set_esc_handler(id, target, method): - _esc_handlers[identifier(id, [0x30, 0x7e])] = [{'target': target, 'method': method}] +func set_esc_handler(id, target, method, arg = null): + _esc_handlers[identifier(id, [0x30, 0x7e])] = [{'target': target, + 'method': method, 'arg': arg}] func set_esc_handler_fallback(target: Object, method: String): @@ -229,6 +230,7 @@ func parse(data: Array, length: int): ParserAction.EXECUTE: var handler = _execute_handlers.get(code) if handler: + print("EXEC: ", handler['method']) handler['target'].call(handler['method']) elif _execute_handler_fb: _execute_handler_fb['target'].call(_execute_handler_fb['method'], code) @@ -243,6 +245,7 @@ func parse(data: Array, length: int): var handlers = _csi_handlers.get((collect << 8 | code), []) handlers.invert() for handler in handlers: + print("CSI: ", handler['method']) # undefined or true means success and to stop bubbling if handler['target'].call(handler['method'], params): continue @@ -277,8 +280,13 @@ func parse(data: Array, length: int): handlers.invert() for handler in handlers: # undefined or true means success and to stop bubbling - if handler['target'].call(handler['method']) != false: - continue + print("ESC: ", handler['method']) + if handler['arg']: + if handler['target'].call(handler['method'], handler['arg']) != false: + continue + else: + if handler['target'].call(handler['method']) != false: + continue handlers.invert() if handlers.empty(): _esc_handler_fb['target'].call(_esc_handler_fb['method'], collect << 8 | code) diff --git a/addons/godot_xterm/parser/params.gd b/addons/godot_xterm/parser/params.gd index 0059d67..49bf95a 100644 --- a/addons/godot_xterm/parser/params.gd +++ b/addons/godot_xterm/parser/params.gd @@ -75,6 +75,12 @@ func add_param(value: int): params[length] = MAX_VALUE if value > MAX_VALUE else value length += 1 + +# Add a sub parameter value. +# The sub parameter is automatically associated with the last parameter value. +# Thus it is not possible to add a subparameter without any parameter added yet. +# `Params` only stores up to `subParamsLength` sub parameters, any later +# sub parameter will be ignored. func add_sub_param(value: int): digit_is_sub = true if !length: @@ -88,6 +94,21 @@ func add_sub_param(value: int): sub_params_length += 1 sub_params_idx[length - 1] += 1 + +# Whether parameter at index `idx` has sub parameters. +func has_sub_params(idx: int) -> bool: + return (sub_params_idx[idx] & 0xFF) - (sub_params_idx[idx] >> 8) > 0 + + +func get_sub_params(idx: int): + var start = sub_params_idx[idx] >> 8 + var end = sub_params_idx[idx] & 0xFF + if end - start > 0: + return sub_params.slice(start, end - 1) + else: + return null + + func add_digit(value: int): var _length = sub_params_length if digit_is_sub else length if _reject_digits or (not _length) or (digit_is_sub and _reject_sub_digits): @@ -97,10 +118,6 @@ func add_digit(value: int): store[_length - 1] = min(cur * 10 + value, MAX_VALUE) if ~cur else value -func size(): - return params.size() - - func to_array(): var res = [] for i in range(length): diff --git a/addons/godot_xterm/renderer/base_render_layer.gd b/addons/godot_xterm/renderer/base_render_layer.gd deleted file mode 100644 index 8b819b1..0000000 --- a/addons/godot_xterm/renderer/base_render_layer.gd +++ /dev/null @@ -1,250 +0,0 @@ -# Copyright (c) 2017 The xterm.js authors. All rights reserved. -# Ported to GDScript by the GodotXterm authors. -# License MIT -extends Reference - - -const Constants = preload("res://addons/godot_xterm/buffer/constants.gd") -const AttributeData = preload("res://addons/godot_xterm/buffer/attribute_data.gd") -const CanvasRenderingContext2D = preload("res://addons/godot_xterm/renderer/canvas_rendering_context_2d.gd") - - -const Attributes = Constants.Attributes -# TODO: Something about these consts and atlas -const INVERTED_DEFAULT_COLOR = Color(0, 0, 0, 1) -const DEFAULT_COLOR = Color(1, 1, 1, 0) - -var _container: Node -var id: String -var z_index: int -var _alpha: bool -var _colors -var _renderer_id: int -var _buffer_service -var _options_service - -var _ctx: CanvasRenderingContext2D -var _scaled_char_width: int = 0 -var _scaled_char_height: int = 0 -var _scaled_cell_width: int = 0 -var _scaled_cell_height: int = 0 -var _scaled_char_left: int = 0 -var _scaled_char_top: int = 0 -var _char_atlas - - -# An object that's reused when drawing glyphs in order to reduce GC. -class GlyphIdentifier: - extends Reference - var chars = '' - var code = 0 - var bg = 0 - var fg = 0 - var bold = false - var dim = false - var italic = false - -var _current_glyph_identifier = GlyphIdentifier.new() - - -func _init(container: Node, id: String, z_index: int, alpha: bool, - colors: Dictionary, renderer_id: int, buffer_service, options_service): - _container = container - self.id = id - self.z_index = z_index - _alpha = alpha - _colors = colors - _renderer_id = renderer_id - _buffer_service = buffer_service - _options_service = options_service - - _ctx = CanvasRenderingContext2D.new() - _ctx.z_index = z_index - _container.add_child(_ctx) - - - -func on_grid_changed(start_row: int, end_row: int) -> void: - pass - - -func resize(dim) -> void: - _scaled_cell_width = dim.scaled_cell_width - _scaled_cell_height = dim.scaled_cell_height - _scaled_char_width = dim.scaled_char_width - _scaled_char_height = dim.scaled_char_height - _scaled_char_left = dim.scaled_char_left - _scaled_char_top = dim.scaled_char_top - #_canvas_width = dim.scaled_canvas_width - #_canvas_height = dim.scaled_canvas_height - #this._canvas.style.width = `${dim.canvasWidth}px`; - #this._canvas.style.height = `${dim.canvasHeight}px`; - -func _fill_cells(x: int, y: int, width: int, height: int) -> void: - _ctx.fill_rect(Rect2(x * _scaled_cell_width, y * _scaled_cell_height, - width * _scaled_cell_width, height * _scaled_cell_height)) - -func _clear_cells(x: int, y: int, width: int, height: int) -> void: - var scaled = Rect2(x * _scaled_cell_width, y * _scaled_cell_height, - width * _scaled_cell_width, height * _scaled_cell_height) - - if _alpha: - _ctx.clear_rect(scaled) - else: - _ctx.fill_style = _colors.background - _ctx.fill_rect(scaled) - - -func _draw_chars(cell, x, y) -> void: - # TODO - #var contrast_color = _get_contrast_color(cell) - var contrast_color = null - - # skip cache right away if we draw in RGB - # Note: to avoid bad runtime JoinedCellData will be skipped - # in the cache handler itself (atlasDidDraw == false) and - # fall through to uncached later down below - if contrast_color or cell.is_fg_rgb() or cell.is_bg_rgb(): - _draw_uncached_chars(cell, x, y, contrast_color) - return - - var fg - var bg - if cell.is_inverse(): - fg = INVERTED_DEFAULT_COLOR if cell.is_bg_default() else cell.get_bg_color() - bg = INVERTED_DEFAULT_COLOR if cell.is_fg_default() else cell.get_fg_color() - else: - bg = DEFAULT_COLOR if cell.is_bg_default() else cell.get_bg_color() - fg = DEFAULT_COLOR if cell.is_fg_default() else cell.get_fg_color() - - var draw_in_bright_color = _options_service.options.draw_bold_text_in_bright_colors and cell.is_bold() and fg < 8 - - fg = Color(fg as int + 8) if draw_in_bright_color else 0 - _current_glyph_identifier.chars = cell.get_chars() if cell.get_chars() else Constants.WHITESPACE_CELL_CHAR - _current_glyph_identifier.code = cell.get_code() if cell.get_code() else Constants.WHITESPACE_CELL_CODE - _current_glyph_identifier.bg = bg - _current_glyph_identifier.fg = fg - _current_glyph_identifier.bold = cell.is_bold() as bool - _current_glyph_identifier.dim = cell.is_dim() as bool - _current_glyph_identifier.italic = cell.is_italic() as bool - var atlas_did_draw = _char_atlas and _char_atlas.draw(_ctx, - _current_glyph_identifier, x * _scaled_cell_width + _scaled_char_left, - y * _scaled_cell_width, _scaled_char_top) - - if not atlas_did_draw: - _draw_uncached_chars(cell, x, y) - - -# Draws one or more charaters at one or more cells. The character(s) will be -# clipped to ensure that they fit with the cell(s), including the cell to the -# right if the last character is a wide character. -func _draw_uncached_chars(cell, x: int, y: int, fg_override = null) -> void: - _ctx.save() - _ctx.font = _get_font(cell.is_bold() as bool, cell.is_italic() as bool) - - if cell.is_inverse(): - if cell.is_bg_default(): - _ctx.fill_style = _colors.background - elif cell.is_bg_rgb(): - _ctx.fill_style = AttributeData.to_color_rgb(cell.get_bg_color()) - else: - var bg = cell.get_bg_color() - if _options_service.options.draw_bold_text_in_bright_colors and cell.is_bold() and bg < 8: - bg += 8 - _ctx.fill_style = _colors.ansi[bg] - else: - if cell.is_fg_default(): - _ctx.fill_style = _colors.foreground - elif cell.is_fg_rgb(): - _ctx.fill_style = AttributeData.to_color_rgb(cell.get_fg_color()) - else: - var fg = cell.get_fg_color() - if _options_service.options.draw_bold_text_in_bright_colors and cell.is_bold() and fg < 8: - fg += 8 - _ctx.fill_style = _colors.ansi[fg] - - #_clip_row(y) - - # Apply alpha to dim the character - if cell.is_dim(): - pass - #_ctx.global_alpha = DIM_OPACITY - # Draw the character - _ctx.fill_text(cell.get_chars(), x * _scaled_cell_width + _scaled_char_left, - y * _scaled_cell_height + _scaled_char_top + _scaled_char_height / 2) - _ctx.restore() - -func _get_font(is_bold: bool, is_italic: bool) -> Font: - var font_family = _options_service.options.font_family - - if is_bold and is_italic and font_family.bold_italic: - return font_family.bold_italic - elif is_bold and font_family.bold: - return font_family.bold - elif is_italic and font_family.italic: - return font_family.italic - else: - return font_family.regular - - -func _get_contrast_color(cell): - if _options_service.options.minimum_contrast_ratio == 1: - return null - - var adjusted_color = _colors.contrast_cache.get_color(cell.bg, cell.fg) - if adjusted_color != null: - return adjusted_color - - var fg_color = cell.get_fg_color() - var fg_color_mode = cell.get_fg_color_mode() - var bg_color = cell.get_bg_color() - var bg_color_mode = cell.get_bg_color_mode() - var is_inverse = cell.is_inverse() as bool - var is_bold = cell.is_bold() as bool - if is_inverse: - var temp = fg_color - fg_color = bg_color - bg_color = temp - var temp2 = fg_color_mode - fg_color_mode = bg_color_mode - bg_color_mode = temp2 - - var bg_rgba = _resolve_background_rgba(bg_color_mode, bg_color, is_inverse) - var fg_rgba = _resolve_foreground_rgba(fg_color_mode, fg_color, is_inverse, is_bold) - # TODO - #var result = rgba.ensure_contrast_ratio(bg_rgba, fg_rgba, _options_service.options.minimum_contrast_ratio) - -func _resolve_background_rgba(bg_color_mode: int, bg_color: int, inverse: bool) -> int: - match bg_color_mode: - Attributes.CM_P16, Attributes.CM_P256: - return _colors.ansi[bg_color].rgba - Attributes.CM_RGB: - return bg_color << 8 - Attributes.CM_DEFAULT, _: - if inverse: - return _colors.foreground.rgba - else: - return _colors.background.rgba - - -func _resolve_foreground_rgba(fg_color_mode: int, fg_color: int, inverse: bool, bold: bool): - match fg_color_mode: - Attributes.CM_P16, Attributes.CM_P256: - if _options_service.options.draw_bold_text_in_bright_colors and bold and fg_color < 8: - return _colors.ansi[fg_color].rgba - Attributes.CM_RGB: - return fg_color << 8 - Attributes.CM_DEFAULT, _: - if inverse: - return _colors.background.rgba - else: - return _colors.foreground.rgba - - - - - - - - - diff --git a/addons/godot_xterm/renderer/renderer.gd b/addons/godot_xterm/renderer/renderer.gd deleted file mode 100644 index b342b02..0000000 --- a/addons/godot_xterm/renderer/renderer.gd +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (c) 2017 The xterm.js authors. All rights reserved. -# Ported to GDScript by the GodotXterm authors. -# License MIT -extends Reference - - -const CharacterJoinerRegistry = preload("res://addons/godot_xterm/renderer/character_joiner_registry.gd") -const TextRenderLayer = preload("res://addons/godot_xterm/renderer/text_render_layer.gd") - -signal redraw_requested -signal options_changed -signal grid_changed(start, end) - -var _id: int -var _render_layers: Array -var _device_pixel_ratio: float -var _character_joiner_registry -var _colors -var _container -var _buffer_service -var _options_service -var _char_size_service - -var dimensions - - -func _init(colors, container: Node, buffer_service, options_service): - _id = get_instance_id() - _colors = colors - _container = container - _buffer_service = buffer_service - _options_service = options_service - - var allow_transparency = _options_service.options.allow_transparency - _character_joiner_registry = CharacterJoinerRegistry.new(_buffer_service) - - _render_layers = [ - TextRenderLayer.new(_container, 0, _colors, _character_joiner_registry, - allow_transparency, _id, _buffer_service, _options_service) - ] - - # Connect render layers to our signals. - for layer in _render_layers: - self.connect("options_changed", layer, "on_options_changed") - self.connect("grid_changed", layer, "on_grid_changed") - - dimensions = { - "scaled_char_width": 0, - "scaled_char_height": 0, - "scaled_cell_width": 0, - "scaled_cell_height": 0, - "scaled_char_left": 0, - "scaled_char_top": 0, - "scaled_canvas_width": 0, - "scaled_canvas_height": 0, - "canvas_width": 0, - "canvas_height": 0, - "actual_cell_width": 0, - "actual_cell_height": 0, - } - _device_pixel_ratio = OS.get_screen_dpi() - _update_dimensions() - emit_signal("options_changed") - - -func on_resize(cols, rows): - # Update character and canvas dimensions - _update_dimensions() - - # Resize all render layers - for layer in _render_layers: - layer.resize(dimensions) - - -func refresh_rows(start: int, end: int) -> void: - emit_signal("grid_changed", start, end) - - -# Recalculates the character and canvas dimensions. -func _update_dimensions(): - var char_width = 0 - var char_height = 0 - - for font in _options_service.options.font_family.values(): - var size = font.get_string_size("W") - char_width = max(char_width, size.x) - char_height = max(char_height, size.y) - - dimensions.scaled_char_width = char_width - dimensions.scaled_char_height = char_height - - # Calculate the scaled cell height, if line_height is not 1 then the value - # will be floored because since line_height can never be lower then 1, there - # is a guarantee that the scaled line height will always be larger than - # scaled char height. - dimensions.scaled_cell_height = floor(dimensions.scaled_char_height * _options_service.options.line_height) - - # Calculate the y coordinate within a cell that text should draw from in - # order to draw in the center of a cell. - dimensions.scaled_char_top = 0 if _options_service.options.line_height == 1 else \ - round((dimensions.scaled_cell_height - dimensions.scaled_char_height) / 2) - - # Calculate the scaled cell width, taking the letter_spacing into account. - dimensions.scaled_cell_width = dimensions.scaled_char_width + round(_options_service.options.letter_spacing) - - # Calculate the x coordinate with a cell that text should draw from in - # order to draw in the center of a cell. - dimensions.scaled_char_left = floor(_options_service.options.letter_spacing / 2) - - - - - - - - - - diff --git a/addons/godot_xterm/renderer/text_render_layer.gd b/addons/godot_xterm/renderer/text_render_layer.gd deleted file mode 100644 index e73c540..0000000 --- a/addons/godot_xterm/renderer/text_render_layer.gd +++ /dev/null @@ -1,191 +0,0 @@ -# Copyright (c) 2017 The xterm.js authors. All rights reserved. -# Ported to GDScript by the GodotXterm authors. -# License MIT -extends "res://addons/godot_xterm/renderer/base_render_layer.gd" - - -const CellData = preload("res://addons/godot_xterm/buffer/cell_data.gd") -const CharacterJoinerRegistry = preload("res://addons/godot_xterm/renderer/character_joiner_registry.gd") -const JoinedCellData = CharacterJoinerRegistry.JoinedCellData -const Content = Constants.Content - -var _state -var _character_width: int = 0 -var _character_font: DynamicFont = preload("res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres") -var _character_overlap_cache: Dictionary = {} -var _character_joiner_registry -var _work_cell = CellData.new() - - -func _init(container: Node, z_index: int, colors, character_joiner_registry, - alpha: bool, renderer_id: int, buffer_service, options_service).(container, - 'text', z_index, alpha, colors, renderer_id, buffer_service, options_service): - _state = null #TODO what? - _character_joiner_registry = character_joiner_registry - - -func on_grid_changed(first_row: int, last_row: int) -> void: - _clear_cells(0, first_row, _buffer_service.cols, last_row - first_row + 1) - _draw_background(first_row, last_row) - _draw_foreground(first_row, last_row) - - # Finally draw everything that has been queued in the draw buffer. - _ctx.update() - - -func on_options_changed() -> void: - pass - - -func _cells(first_row: int, last_row: int, joiner_registry = null) -> Array: - var cells = [] - - for y in range(first_row, last_row + 1): - var row = y + _buffer_service.buffer.ydisp - var line = _buffer_service.buffer.lines.get_el(row) - var joined_ranges = joiner_registry.get_joined_characters(row) if joiner_registry else [] - for x in range(_buffer_service.cols): - line.load_cell(x, _work_cell) - var cell = _work_cell - - # If true, indicates that the current character(s) to draw were joined. - var is_joined = false - var last_char_x = x - - # The character to the left is a wide character, drawing is owned by - # the char at x-1 - if cell.get_width() == 0: - continue - - # Process any joined character range as needed. Because of how the - # ranges are produced, we know that they are valid for the characters - # and attributes of our input. - if not joined_ranges.empty() and x == joined_ranges[0][0]: - is_joined = true - var r = joined_ranges.pop_front() - - # We already know the exact start and end column of the joined - # range, so we get the string and width representing it directly - - cell = JoinedCellData.new(_work_cell, - line.trans_late_to_string(true, r[0], r[1]), r[1] - r[0]) - - # Skip over the cells occupied by this range in the loop - last_char_x = r[1] - 1 - - # If the character is an overlapping char and the character to the - # right is a space, take ownership of the cell to the right. We skip - # this check for joined characters because their rendering likely won't - # yield the same result as rendering the last character individually. - if not is_joined and _is_overlapping(cell): - if last_char_x < line.length - 1 and line.get_codepoint(last_char_x + 1) == Constants.NULL_CELL_CODE: - # patch width to 2 - cell.content &= ~Content.WIDTH_MASK - cell.content |= 2 << Content.WIDTH_SHIFT - - # Append a new instance of cell, as we wil reuse the current instance. - cells.append({"cell": CellData.from_char_data(cell.get_as_char_data()), - "x": x, "y": y}) - - x = last_char_x - - return cells - - -func _draw_background(first_row: int, last_row: int) -> void: - var ctx = _ctx - var cols = _buffer_service.cols - var start_x = 0 - var start_y = 0 - var prev_fill_style = null - - ctx.save() - - for c in _cells(first_row, last_row, null): - var cell = c.cell - var x = c.x - var y = c.y - - # libvte and xterm draw the background (but not foreground) of invisible characters, - # so we should too. - var next_fill_style = null # null represents the default background color - - if cell.is_inverse(): - if cell.is_fg_default(): - next_fill_style = _colors.foreground - elif cell.is_fg_rgb(): - next_fill_style = cell.get_fg_color() # TODO: Figure out how to convert this to Color() - else: - next_fill_style = _colors.ansi[cell.get_fg_color()] - elif cell.is_bg_rgb(): - next_fill_style = cell.get_bg_color() # TODO: Figure out how to convert this to Color() - elif cell.is_bg_palette(): - next_fill_style = _colors.ansi[cell.get_bg_color()] - - if prev_fill_style == null: - # This is either the first iteration, or the default background was set. Either way, we - # don't need to draw anything. - start_x = x - start_y = y - - if y != start_y: - # our row changed, draw the previous row - ctx.fill_style = prev_fill_style if prev_fill_style else Color() - _fill_cells(start_x, start_y, cols - start_x, 1) - start_x = x - start_y = y - elif prev_fill_style != next_fill_style: - # our color changed, draw the previous characters in this row - ctx.fill_style = prev_fill_style if prev_fill_style else Color() - start_x = x - start_y = y - - prev_fill_style = next_fill_style - - # flush the last color we encountered - if prev_fill_style != null: - ctx.fill_style = prev_fill_style - _fill_cells(start_x, start_y, cols - start_x, 1) - - ctx.restore() - - -func _draw_foreground(first_row: int, last_row: int) -> void: - for c in _cells(first_row, last_row, _character_joiner_registry): - var cell = c.cell - var x = c.x - var y = c.y - - if cell.is_invisible(): - return - - _draw_chars(cell, x, y) - - -func _is_overlapping(cell) -> bool: - # Only single cell characters can be overlapping, rendering issues can - # occur without this check - if cell.get_width() != 1: - return false - - var chars = cell.get_chars() - - # Deliver from cache if available - if _character_overlap_cache.has(chars): - return _character_overlap_cache[chars] - - # Setup the font - _ctx.save() - _ctx.font = _character_font - - # Measure the width of the character, but floor it - # because that is what the renderer does when it calculates - # the character dimensions wer are comparing against - var overlaps = floor(_ctx.measure_text(chars).width) > _character_width - - # Restore the original context - _ctx.restore() - - # Cache and return - _character_overlap_cache[chars] = overlaps - return overlaps diff --git a/addons/godot_xterm/services/buffer_service.gd b/addons/godot_xterm/services/buffer_service.gd index c6b2498..62d7124 100644 --- a/addons/godot_xterm/services/buffer_service.gd +++ b/addons/godot_xterm/services/buffer_service.gd @@ -4,7 +4,7 @@ extends Reference signal buffer_activated(active_buffer, inactive_buffer) -signal resized +signal resized(cols, rows) const BufferSet = preload("res://addons/godot_xterm/buffer/buffer_set.gd") @@ -29,11 +29,25 @@ func _get_buffer(): func _init(options_service): _options_service = options_service + _options_service.connect("option_changed", self, "_option_changed") cols = max(_options_service.options.cols, MINIMUM_COLS) rows = max(_options_service.options.rows, MINIMUM_ROWS) buffers = BufferSet.new(_options_service, self) buffers.connect("buffer_activated", self, "_buffer_activated") +func resize(cols: int, rows: int) -> void: + self.cols = cols + self.rows = rows + buffers.resize(cols, rows) + #buffers.setup_tab_stops(cols) + emit_signal("resized", cols, rows) + + func _buffer_activated(active_buffer, inactive_buffer): emit_signal("buffer_activated", active_buffer, inactive_buffer) + + +func _option_changed(option: String) -> void: + if option == "cols" or option == "rows": + resize(_options_service.options.cols, _options_service.options.rows) diff --git a/addons/godot_xterm/services/options_service.gd b/addons/godot_xterm/services/options_service.gd index e82c942..f056729 100644 --- a/addons/godot_xterm/services/options_service.gd +++ b/addons/godot_xterm/services/options_service.gd @@ -4,45 +4,72 @@ extends Reference -class TerminalOptions: - var cols: int - var rows: int - var cursor_blink: bool - var cursor_style - var cursor_width: int - var bell_sound - var bell_style - var draw_bold_text_in_bright_colors: bool - var fast_scroll_modifier - var fast_scroll_sensitivity: int - var font_family: Dictionary - var font_size: int - var font_weight: String - var font_weight_bold: String - var line_height: float - var link_tooltip_hover_duration: int - var letter_spacing: float - var log_level - var scrollback: int - var scroll_sensitivity: int - var screen_reader_mode: bool - var mac_option_is_meta: bool - var mac_option_click_forces_selection: bool - var minimum_contrast_ratio: float - var disable_stdin: bool - var allow_proposed_api: bool - var allow_transparency: bool - var tab_stop_width: int - var colors: Dictionary - var right_click_selects_word - var renderer_type - var window_options: Dictionary - var windows_mode: bool - var word_separator: String - var convert_eol: bool - var term_name: String - var cancel_events: bool +const Constants = preload("res://addons/godot_xterm/buffer/constants.gd") +const CursorStyle = Constants.CursorStyle +const UnderlineStyle = Constants.UnderlineStyle +const BellStyle = Constants.BellStyle + + +class TerminalOptions: + extends Reference + + + var cols: int = 80 + var rows: int = 24 + var cursor_blink: bool = false + var cursor_style = CursorStyle.BLOCK +# var cursor_width: int = 1 +# var bell_sound: AudioStream = null +# var bell_style = BellStyle.NONE +# var draw_bold_text_in_bright_colors: bool = true +# var fast_scroll_modifier = "alt" +# var fast_scroll_sensitivity: int = 5 + var font_family: Dictionary = { + # TODO + } + var font_size: int = 15 +# var font_weight: String # TODO: Remove +# var font_weight_bold: String # TODO: Remove + var line_height: float = 1.0 +# var link_tooltip_hover_duration: int # TODO: Remove + var letter_spacing: float = 0 +# var log_level # TODO: implement + var scrollback: int = 1000 +# var scroll_sensitivity: int = 1 + var screen_reader_mode: bool = false +# var mac_option_is_meta: bool = false +# var mac_option_click_forces_selection: bool = false +# var minimum_contrast_ratio: float = 1 +# var disable_stdin: bool = false +# var allow_proposed_api: bool = true + var allow_transparency: bool = false + var tab_stop_width: int = 8 +# var colors: Dictionary = { +# 'black': Color(0, 0, 0) +# } +# var right_click_selects_word = "isMac" # TODO? +# var renderer_type = "canvas" # Remove? + var window_options: Dictionary = { + 'set_win_lines': false, + } + var windows_mode: bool = false +# var word_separator: String = " ()[]{}',\"" + var convert_eol: bool = true +# var term_name: String = "xterm" +# var cancel_events: bool = false + + + # Copies options from an `object` to itself. + func copy_from(object: Object): + for property in get_property_list(): + if property.usage == PROPERTY_USAGE_SCRIPT_VARIABLE: + var p = object.get(property.name) + if p: + set(property.name, p) + + +var DEFAULT_OPTIONS = TerminalOptions.new() signal option_changed @@ -51,3 +78,27 @@ var options func _init(options): self.options = options + + # Set the font size based on the font_size option + _resize_fonts() + + +func set_option(key: String, value) -> void: + # TODO: sanitize and validate options. + + # Don't fire an option change event if they didn't change + if options[key] == value: + return + + options[key] = value + emit_signal("option_changed", key) + + # Update other options accordingly. + match key: + "font_size": + _resize_fonts() + + +func _resize_fonts(): + for font in options.font_family.values(): + font.size = options.font_size diff --git a/addons/godot_xterm/terminal.gd b/addons/godot_xterm/terminal.gd index 36ce5b3..fd7fbd8 100644 --- a/addons/godot_xterm/terminal.gd +++ b/addons/godot_xterm/terminal.gd @@ -33,9 +33,9 @@ const Const = preload("res://addons/godot_xterm/Constants.gd") const Constants = preload("res://addons/godot_xterm/parser/constants.gd") const Parser = preload("res://addons/godot_xterm/parser/escape_sequence_parser.gd") const Decoder = preload("res://addons/godot_xterm/input/text_decoder.gd") -const Renderer = preload("res://addons/godot_xterm/renderer/renderer.gd") const ColorManager = preload("res://addons/godot_xterm/color_manager.gd") const CellData = preload("res://addons/godot_xterm/buffer/cell_data.gd") +const AttributeData = preload("res://addons/godot_xterm/buffer/attribute_data.gd") const SourceCodeProRegular = preload("res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_regular.tres") const SourceCodeProBold = preload("res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.tres") @@ -52,15 +52,21 @@ const LEFT_BRACKET = 91 const ENTER = 10 const BACKSPACE_ALT = 127 +const BLINK_INTERVAL = 0.6 # 600ms. The time between blinks. + # TODO: Move me somewhere else. enum BellStyle { NONE } signal output(data) +signal scrolled(ydisp) export var cols = 80 export var rows = 24 +# If set, terminals rows and cols will be automatically calculated based on the +# control's rect size and font_size. +export var auto_resize = false export var cursor_blink = false export var cursor_style = 'block' export var cursor_width = 1 @@ -76,8 +82,6 @@ export var font_family: Dictionary = { "bold_italic": SourceCodeProBoldItalic, } export var font_size: int = 15 -export var font_weight = 'normal' # Enum? -export var font_weight_bold = 'bold' # Enum? export var line_height = 1.0 export var link_tooltip_hover_duration = 500 # Not relevant? export var letter_spacing = 0 @@ -93,7 +97,22 @@ export var allow_proposed_api = true export var allow_transparency = false export var tab_stop_width = 8 export var colors: Dictionary = { - 'black': Color(0, 0, 0) + "black": Color("#2e3436"), + "red": Color("#cc0000"), + "green": Color("#4e9a06"), + "yellow": Color("#c4a000"), + "blue": Color("#3465a4"), + "magenta": Color("#75507b"), + "cyan": Color("#06989a"), + "white": Color("#d3d7cf"), + "bright_black": Color("#555753"), + "bright_red": Color("#ef2929"), + "bright_green": Color("#8ae234"), + "bright_yellow": Color("#fce94f"), + "bright_blue": Color("#729fcf"), + "bright_magenta": Color("#ad7fa8"), + "bright_cyan": Color("#34e2e2"), + "bright_white": Color("#eeeeec"), } export var right_click_selects_word = 'isMac' # TODO export var renderer_type = 'canvas' # Relevant? @@ -122,19 +141,13 @@ var _scaled_cell_height var _scaled_char_top var _scaled_char_left var _work_cell = CellData.new() +var _blink_on = false +var _time_since_last_blink = 0 func _ready(): var options = OptionsService.TerminalOptions.new() - options.cols = cols - options.rows = rows - options.font_family = font_family - options.line_height = line_height - options.screen_reader_mode = screen_reader_mode - options.window_options = window_options - options.convert_eol = convert_eol - + options.copy_from(self) options_service = OptionsService.new(options) - options_service.connect("option_changed", self, "_update_options") _buffer_service = BufferService.new(options_service) _core_service = CoreService.new() @@ -151,9 +164,10 @@ func _ready(): _color_manager = ColorManager.new() _color_manager.set_theme(colors) - _render_service = Renderer.new(_color_manager.colors, self, _buffer_service, options_service) - connect("resized", self, "_update_dimensions") + if auto_resize: + connect("resized", self, "_update_dimensions") + _update_dimensions() @@ -231,21 +245,146 @@ func _update_dimensions(): # Calculate the x coordinate with a cell that text should draw from in # order to draw in the center of a cell. _scaled_char_left = floor(options_service.options.letter_spacing / 2) + + if auto_resize: + # Calculate cols and rows based on cell size + var rect: Rect2 = get_rect() + var cols = max(2, floor(rect.size.x / _scaled_cell_width)) + var rows = max(1, floor(rect.size.y / _scaled_cell_height)) + + self.cols = cols + self.rows = rows + + options_service.set_option("rows", rows) + options_service.set_option("cols", cols) + + +# Scroll the terminal down 1 row, creating a blank line. +# @param is_wrapped Whether the new line is wrapped from the previous line. +func scroll(erase_attr, is_wrapped: bool = false) -> void: + var buffer = _buffer_service.buffer + var new_line = buffer.get_blank_line(erase_attr, is_wrapped) + + var top_row = buffer.ybase + buffer.scroll_top + var bottom_row = buffer.ybase + buffer.scroll_bottom + + if buffer.scroll_top == 0: + # Determine whether the buffer is going to be trimmed after insertion. + var will_buffer_be_trimmed = buffer.lines.is_full + + # Insert the line using the fastest method + if bottom_row == buffer.lines.length - 1: + if will_buffer_be_trimmed: + buffer.lines.recycle().copy_from(new_line.duplicate()) + else: + buffer.lines.push(new_line.duplicate()) + else: + buffer.lines.splice(bottom_row + 1, 0, [new_line.duplicate()]) + + # Only adjust ybase and ydisp when the buffer is not trimmed + if not will_buffer_be_trimmed: + buffer.ybase += 1 + # Only scroll the ydisp with ybase if the user has not scrolled up + if not _buffer_service.is_user_scrolling: + buffer.ydisp += 1 + else: + # When the buffer is full and the user has scrolled up, keep the text + # stable unless ydisp is right at the top + if _buffer_service.is_user_scrolling: + buffer.ydisp = max(buffer.ydisp - 1, 0) + else: + # scroll_top is non-zero which means no line will be going to the + # scrollback, instead we can just shift them in-place. + var scroll_region_height = bottom_row - top_row + 1 # as it's zero based + buffer.lines.shift_elements(top_row + 1, scroll_region_height - 1, -1) + buffer.lines.set_line(bottom_row, new_line.duplicate()) + + # Move the viewport to the bottom of the buffer unless the user is scrolling. + if not _buffer_service.is_user_scrolling: + buffer.ydisp = buffer.ybase + + # Flag rows that need updating + # TODO + + emit_signal("scrolled", buffer.ydisp) + + +func _process(delta): + _time_since_last_blink += delta + if _time_since_last_blink > BLINK_INTERVAL: + _blink_on = not _blink_on + _time_since_last_blink = 0 + update() func _draw(): # Draw the background and foreground + if _buffer_service == null: + return + var buffer = _buffer_service.buffer - for y in range(buffer.ybase, rows): - var line = buffer.lines.get_el(y) + var rows = _buffer_service.rows + + for y in range(0, rows): + var row = y + buffer.ydisp + var line = buffer.lines.get_line(row) for x in line.length: line.load_cell(x, _work_cell) + + # Background + + # Get the background color + # TODO: handle inverse + var bg_color + if _work_cell.is_bg_rgb(): + bg_color = AttributeData.to_color_rgb(_work_cell.get_bg_color()) + elif _work_cell.is_bg_palette(): + bg_color = _color_manager.colors.ansi[_work_cell.get_bg_color()] + else: + bg_color = _color_manager.colors.background + draw_rect(Rect2(x * _scaled_cell_width, y * _scaled_cell_height, - (cols - x) * _scaled_cell_width, 1 * _scaled_cell_height), Color()) - var color = _color_manager.colors.ansi[_work_cell.get_fg_color()] if _work_cell.get_fg_color() >= 0 else Color(1, 1, 1) - draw_char(options_service.options.font_family.regular, + (cols - x) * _scaled_cell_width, 1 * _scaled_cell_height), + bg_color) + + # Foreground + # Don't draw if cell is invisible + if _work_cell.is_invisible(): + continue + + # Don't draw if cell is blink and blink is off + if _work_cell.is_blink() and not _blink_on: + continue + + # Get the foreground color + # TODO: handle inverse min contrast and draw bold in bright colors + # dim and maybe more! + var fg_color + if _work_cell.is_fg_default(): + fg_color = _color_manager.colors.foreground + if _work_cell.is_fg_rgb(): + fg_color = AttributeData.to_color_rgb(_work_cell.get_fg_color()) + else: + fg_color = _color_manager.colors.ansi[_work_cell.get_fg_color()] + + # Get font + var font: DynamicFont = options_service.options.font_family.regular + var is_bold = _work_cell.is_bold() + var is_italic = _work_cell.is_italic() + + if is_bold and is_italic: + font = options_service.options.font_family.bold_italic + elif is_bold: + font = options_service.options.font_family.bold + elif is_italic: + font = options_service.options.font_family.italic + + # TODO: set this once initially + font.size = options_service.options.font_size + + draw_char(font, Vector2(x * _scaled_cell_width + _scaled_char_left, y * _scaled_cell_height + _scaled_char_top + _scaled_char_height / 2), - _work_cell.get_chars() if _work_cell.get_chars() else ' ', "", color) + _work_cell.get_chars() if _work_cell.get_chars() else ' ', "", fg_color) # Draw the cursor # Draw selection diff --git a/demo.cast b/demo.cast new file mode 100644 index 0000000..afba5c6 --- /dev/null +++ b/demo.cast @@ -0,0 +1,61 @@ +{"version": 2, "width": 86, "height": 29, "timestamp": 1589772748, "env": {"SHELL": "/run/current-system/sw/bin/bash", "TERM": "xterm"}} +[0.082961, "o", "> "] +[0.798002, "o", "e"] +[0.893414, "o", "c"] +[0.956255, "o", "h"] +[1.008677, "o", "o"] +[1.089472, "o", " "] +[1.189602, "o", "h"] +[1.266892, "o", "e"] +[1.347483, "o", "l"] +[1.46568, "o", "l"] +[1.541039, "o", "o"] +[1.726772, "o", "\r\n"] +[1.727475, "o", "hello\r\n> "] +[2.060109, "o", "#"] +[2.179668, "o", " "] +[2.471941, "o", "T"] +[2.652735, "o", "h"] +[2.746515, "o", "i"] +[2.810578, "o", "s"] +[2.921342, "o", " "] +[2.98886, "o", "i"] +[3.069095, "o", "s"] +[3.31728, "o", " "] +[3.399615, "o", "a"] +[3.513605, "o", " "] +[3.72609, "o", "d"] +[3.811197, "o", "e"] +[3.94649, "o", "m"] +[4.047162, "o", "o"] +[4.225042, "o", "\r\n"] +[4.225402, "o", "> "] +[4.935288, "o", "t"] +[5.163552, "o", "o"] +[5.323205, "o", "i"] +[5.46746, "o", "l"] +[5.561098, "o", "et "] +[6.064937, "o", "-"] +[6.41563, "o", "-"] +[6.60443, "o", "g"] +[6.666621, "o", "a"] +[6.768317, "o", "y"] +[6.848917, "o", " "] +[7.076406, "o", "H"] +[7.250067, "o", "E"] +[7.410878, "o", "L"] +[7.537016, "o", "L"] +[7.604155, "o", "O"] +[7.888992, "o", " "] +[8.193437, "o", "W"] +[8.365871, "o", "O"] +[8.454678, "o", "R"] +[8.525163, "o", "L"] +[8.60286, "o", "D"] +[8.873053, "o", "!"] +[9.216434, "o", "\r\n"] +[9.251462, "o", " \r\n \u001b[0;1;31;91mm\u001b[0m \u001b[0;1;36;96mm\u001b[0m \u001b[0;1;34;94mmm\u001b[0;1;35;95mmm\u001b[0;1;31;91mmm\u001b[0m \u001b[0;1;33;93mm\u001b[0m \u001b[0;1;35;95mm\u001b[0m \u001b[0;1;36;96mmm\u001b[0;1;34;94mmm\u001b[0m \u001b[0;1;36;96mm\u001b[0m \u001b[0;1;31;91mm\u001b[0m \u001b[0;1;33;93mm\u001b[0;1;32;92mmm\u001b[0;1;36;96mm\u001b[0m \u001b[0;1;34;94mm\u001b[0;1;35;95mmm\u001b[0;1;31;91mmm\u001b[0m \u001b[0;1;32;92mm\u001b[0m \u001b[0;1;35;95mm\u001b[0;1;31;91mmm\u001b[0;1;33;93mm\u001b[0m \r\n \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;34;94m#\u001b[0m \u001b[0;1;35;95m#\u001b[0m \u001b[0;1;32;92m#\u001b[0m \u001b[0;1;31;91m#\u001b[0m \u001b[0;1;36;96mm\u001b[0;1;34;94m\"\u001b[0m \u001b[0;1;35;95m\"\u001b[0;1;31;91mm\u001b[0m \u001b[0;1;34;94m#\u001b[0m \u001b[0;1;35;95m#\u001b[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;32;92mm\"\u001b[0m \u001b[0;1;34;94m\"m\u001b[0m \u001b[0;1;35;95m#\u001b[0m \u001b[0;1;33;93m\"\u001b[0;1;32;92m#\u001b[0m \u001b[0;1;36;96m#\u001b[0m \u001b[0;1;31;91m#\u001b[0m \u001b[0;1;32;92m\"\u001b[0;1;36;96mm\u001b[0m\r\n \u001b[0;1;32;92m#\u001b[0;1;36;96mmm\u001b[0;1;34;94mmm\u001b[0;1;35;95m#\u001b[0m \u001b[0;1;31;91m#m\u001b[0;1;33;93mmm\u001b[0;1;32;92mmm\u001b[0m \u001b[0;1;36;96m#\u001b[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;34;94m#\u001b"] +[9.251901, "o", "[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;35;95m\"\u001b[0m \u001b[0;1;31;91m#\"\u001b[0;1;33;93m#\u001b[0m \u001b[0;1;32;92m#\u001b[0m \u001b[0;1;36;96m#\u001b[0m \u001b[0;1;35;95m#\u001b[0m \u001b[0;1;31;91m#\u001b[0;1;33;93mmm\u001b[0;1;32;92mmm\u001b[0;1;36;96m\"\u001b[0m \u001b[0;1;34;94m#\u001b[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;34;94m#\u001b[0m\r\n \u001b[0;1;36;96m#\u001b[0m \u001b[0;1;31;91m#\u001b[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;34;94m#\u001b[0m \u001b[0;1;32;92m#\u001b[0m \u001b[0;1;35;95m#\u001b[0m \u001b[0;1;32;92m#\u001b[0m \u001b[0;1;31;91m#\u001b[0;1;33;93m#\u001b[0m \u001b[0;1;32;92m##\u001b[0;1;36;96m\"\u001b[0m \u001b[0;1;34;94m#\u001b[0m \u001b[0;1;31;91m#\u001b[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;36;96m\"\u001b[0;1;34;94mm\u001b[0m \u001b[0;1;35;95m#\u001b[0m \u001b[0;1;32;92m#\u001b[0m \u001b[0;1;35;95m#\u001b[0m\r\n \u001b[0;1;34;94m#\u001b[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;32;92m#m\u001b[0;1;36;96mmm\u001b[0;1;34;94mmm\u001b[0m \u001b[0;1;35;95m#\u001b[0;1;31;91mmm\u001b[0;1;33;93mmm\u001b[0;1;32;92mm\u001b[0m \u001b[0;1;36;96m#m\u001b[0;1;34;94mmm\u001b[0;1;35;95mmm\u001b[0m \u001b[0;1;33;93m#m\u001b[0;1;32;92mm#\u001b[0m \u001b[0;1;33;93m#\u001b[0m \u001b[0;1;36;96m#\u001b[0m \u001b[0;1;35;95m#\u001b[0;1;31;91mmm\u001b[0;1;33;93m#\u001b[0m \u001b[0;1;32;92m#\u001b[0m \u001b[0;1;35;95m\"\u001b[0m \u001b[0;1;31;91m#m\u001b[0;1;33;93mmm\u001b[0;1"] +[9.251944, "o", ";32;92mmm\u001b[0m \u001b[0;1;36;96m#\u001b[0;1;34;94mmm\u001b[0;1;35;95mm\"\u001b[0m \r\n \r\n \r\n \r\n \u001b[0;1;36;96mm\u001b[0m \r\n \u001b[0;1;34;94m#\u001b[0m \r\n \u001b[0;1;35;95m#\u001b[0m \r\n \u001b[0;1;31;91m\"\u001b[0m \r\n \u001b[0;1;33;93m#\u001b[0m \r\n \r\n \r\n"] +[9.252259, "o", "> "] +[12.56287, "o", "exit\r\n"] diff --git a/scenes/demo.gd b/scenes/demo.gd index ec2e13e..3dbb928 100644 --- a/scenes/demo.gd +++ b/scenes/demo.gd @@ -38,7 +38,6 @@ func _ready(): if err != OK: OS.alert("Couldn't connect to socat on %s:%d" % [host, port], "Connection Failed!") - var status = stream_peer.get_status() var connected = stream_peer.is_connected_to_host() @@ -51,6 +50,13 @@ func _ready(): # Connect the Terminal and StreamPeer. $Terminal.connect('output', self, 'send_data') connect("data_received", $Terminal, "write") + + connect("resized", self, "_resize_terminal") + _resize_terminal() + + +func _resize_terminal(): + $Terminal.rect_size = OS.window_size func send_data(data: PoolByteArray): diff --git a/scenes/demo.tscn b/scenes/demo.tscn index 87f02e0..ac1a8f4 100644 --- a/scenes/demo.tscn +++ b/scenes/demo.tscn @@ -8,6 +8,7 @@ [ext_resource path="res://addons/godot_xterm/fonts/source_code_pro/source_code_pro_bold.tres" type="DynamicFont" id=6] [node name="Demo" type="Control"] +show_behind_parent = true anchor_right = 1.0 anchor_bottom = 1.0 script = ExtResource( 1 ) @@ -16,25 +17,39 @@ __meta__ = { } [node name="Terminal" type="Control" parent="."] -margin_left = 95.937 -margin_top = 44.6138 -margin_right = 695.937 -margin_bottom = 444.614 +anchor_right = 1.0 +anchor_bottom = 1.0 rect_min_size = Vector2( 600, 400 ) script = ExtResource( 2 ) __meta__ = { "_edit_use_anchors_": false } +auto_resize = true font_family = { "bold": ExtResource( 6 ), "bold_italic": ExtResource( 5 ), "italic": ExtResource( 4 ), "regular": ExtResource( 3 ) } -font_size = 16 +line_height = 1.15 colors = { -"black": Color( 0.121569, 0.00784314, 0.00784314, 1 ) +"black": Color( 0.180392, 0.203922, 0.211765, 1 ), +"blue": Color( 0.203922, 0.396078, 0.643137, 1 ), +"bright_black": Color( 0.333333, 0.341176, 0.32549, 1 ), +"bright_blue": Color( 0.447059, 0.623529, 0.811765, 1 ), +"bright_cyan": Color( 0.203922, 0.886275, 0.886275, 1 ), +"bright_green": Color( 0.541176, 0.886275, 0.203922, 1 ), +"bright_magenta": Color( 0.678431, 0.498039, 0.658824, 1 ), +"bright_red": Color( 0.937255, 0.160784, 0.160784, 1 ), +"bright_white": Color( 0.933333, 0.933333, 0.92549, 1 ), +"bright_yellow": Color( 0.988235, 0.913725, 0.309804, 1 ), +"cyan": Color( 0.0235294, 0.596078, 0.603922, 1 ), +"green": Color( 0.305882, 0.603922, 0.0235294, 1 ), +"magenta": Color( 0.458824, 0.313726, 0.482353, 1 ), +"red": Color( 0.8, 0, 0, 1 ), +"white": Color( 0.827451, 0.843137, 0.811765, 1 ), +"yellow": Color( 0.768627, 0.627451, 0, 1 ) } window_options = { - +"set_win_lines": false } diff --git a/test/integration/test_terminal.gd b/test/integration/test_terminal.gd index 99b2309..9eca2b1 100644 --- a/test/integration/test_terminal.gd +++ b/test/integration/test_terminal.gd @@ -100,9 +100,3 @@ func test_csi_position_cursor(): parse(parser, '\u001b[1;5H') assert_eq(buffer.calls, [['csi', [1, 5]]]) assert_eq(buffer.printed, '') - - - - - - diff --git a/test/unit/buffer/test_buffer.gd b/test/unit/buffer/test_buffer.gd index f07cfdd..7871dd8 100644 --- a/test/unit/buffer/test_buffer.gd +++ b/test/unit/buffer/test_buffer.gd @@ -122,7 +122,241 @@ class TestGetWrappedRangeForLine: buffer.lines.get_el(buffer.lines.length - 1).is_wrapped = true assert_eq(buffer.get_wrapped_range_for_line(buffer.lines.length - 2).first, INIT_ROWS - 2) assert_eq(buffer.get_wrapped_range_for_line(buffer.lines.length - 2).last, INIT_ROWS - 1) + + +class TestResize: + extends BaseBufferTest + func before_each(): + .before_each() + buffer.fill_viewport_rows() + func test_column_size_reduction_trims_data_in_the_buffer(): + buffer.resize(INIT_COLS / 2, INIT_ROWS) + assert_eq(buffer.lines.length, INIT_ROWS) + for i in range(INIT_ROWS): + assert_eq(buffer.lines.get_line(i).length, INIT_COLS / 2) + + + func test_column_size_increase_adds_pad_columns(): + buffer.resize(INIT_COLS + 10, INIT_ROWS) + assert_eq(buffer.lines.length, INIT_ROWS) + for i in range(INIT_ROWS): + assert_eq(buffer.lines.get_line(i).length, INIT_COLS + 10) + + + func test_row_size_reduction_trims_blank_lines_from_the_end(): + buffer.resize(INIT_COLS, INIT_ROWS - 10) + assert_eq(buffer.lines.length, INIT_ROWS - 10) + + + func test_row_size_reduction_moves_viewport_down_when_it_is_at_the_end(): + # Set cursor y to have 5 blank lines below it + buffer.y = INIT_ROWS - 5 - 1 + buffer.resize(INIT_COLS, INIT_ROWS - 10) + # Trim 5 rows + assert_eq(buffer.lines.length, INIT_ROWS - 5) + # Shift the viewport down 5 rows + assert_eq(buffer.ydisp, 5) + assert_eq(buffer.ybase, 5) + + + func test_no_scrollback_trims_from_the_top_of_the_buffer_when_the_cursor_reaches_the_bottom(): + buffer = Buffer.new(true, TestUtils.MockOptionsService.new({"scrollback": 0}), buffer_service) + assert_eq(buffer.lines.max_length, INIT_ROWS) + buffer.y = INIT_ROWS - 1 + buffer.fill_viewport_rows() + var ch_data = buffer.lines.get_line(5).load_cell(0, CellData.new()).get_as_char_data() + ch_data[1] = "a" + buffer.lines.get_line(5).set_cell(0, CellData.from_char_data(ch_data)) + ch_data = buffer.lines.get_line(INIT_ROWS - 1).load_cell(0, CellData.new()).get_as_char_data() + ch_data[1] = "b" + buffer.lines.get_line(INIT_ROWS - 1).set_cell(0, CellData.from_char_data(ch_data)) + buffer.resize(INIT_COLS, INIT_ROWS - 5) + assert_eq(buffer.lines.get_line(0).load_cell(0, CellData.new()).get_as_char_data()[1], "a") + assert_eq(buffer.lines.get_line(INIT_ROWS - 1 - 5).load_cell(0, CellData.new()).get_as_char_data()[1], "b") + + + func test_row_size_increase_adds_blank_lines_to_empty_buffer(): + assert_eq(buffer.ydisp, 0) + buffer.resize(INIT_COLS, INIT_ROWS + 10) + assert_eq(buffer.ydisp, 0) + assert_eq(buffer.lines.length, INIT_ROWS + 10) + + func test_row_size_increase_shows_more_of_the_buffer_above(): + # Create 10 extra blank lines + for i in range(10): + buffer.lines.push(buffer.get_blank_line(AttributeData.new())) + # Set cursor to the bottom of the buffer + buffer.y = INIT_ROWS - 1 + # Scroll down 10 lines + buffer.ybase = 10 + buffer.ydisp = 10 + assert_eq(buffer.lines.length, INIT_ROWS + 10) + buffer.resize(INIT_COLS, INIT_ROWS + 5) + # Should be 5 more lines + assert_eq(buffer.ydisp, 5) + assert_eq(buffer.ybase, 5) + # Should not trim the buffer + assert_eq(buffer.lines.length, INIT_ROWS + 10) + + + func test_row_size_increase_shows_more_of_the_buffer_below_when_the_viewort_is_at_the_top_of_the_buffer(): + # Create 10 extra blank lines + for i in range(10): + buffer.lines.push(buffer.get_blank_line(AttributeData.new())) + # Set cursor to the bottom of the buffer + buffer.y = INIT_ROWS - 1 + # Scroll down 10 lines + buffer.ybase = 10 + buffer.ydisp = 0 + assert_eq(buffer.lines.length, INIT_ROWS + 10) + buffer.resize(INIT_COLS, INIT_ROWS + 5) + # The viewport should remain at the top + assert_eq(buffer.ydisp, 0) + # The buffer ybase should move up 5 lines + assert_eq(buffer.ybase, 5) + # Should not trim the buffer + assert_eq(buffer.lines.length, INIT_ROWS + 10) + + + func test_row_and_column_increase_resizes_properly(): + buffer.resize(INIT_COLS + 5, INIT_ROWS + 5) + assert_eq(buffer.lines.length, INIT_ROWS + 5) + buffer.resize(INIT_COLS - 5, INIT_ROWS) + assert_eq(buffer.lines.length, INIT_ROWS) + + + func test_reflow_does_not_wrap_empty_lines(): + assert_eq(buffer.lines.length, INIT_ROWS) + buffer.resize(INIT_COLS - 5, INIT_ROWS) + assert_eq(buffer.lines.length, INIT_ROWS) + + + func test_reflow_shrinks_row_length(): + buffer.resize(5, 10) + assert_eq(buffer.lines.length, 10) + for i in range(10): + assert_eq(buffer.lines.get_line(i).length, 5) + + + func test_reflow_wraps_and_unwraps_lines(): + buffer.resize(5, 10) + var first_line = buffer.lines.get_line(0) + for i in range(5): + var code = "a".ord_at(0) + i + var ch = char(code) + first_line.set_cell(i, CellData.from_char_data([0, ch, 1, code])) + buffer.y = 1 + assert_eq(buffer.lines.get_line(0).length, 5) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "abcde") + buffer.resize(1, 10) + assert_eq(buffer.lines.length, 10) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "a") + assert_eq(buffer.lines.get_line(1).translate_to_string(), "b") + assert_eq(buffer.lines.get_line(2).translate_to_string(), "c") + assert_eq(buffer.lines.get_line(3).translate_to_string(), "d") + assert_eq(buffer.lines.get_line(4).translate_to_string(), "e") + assert_eq(buffer.lines.get_line(5).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(6).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(7).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(8).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(9).translate_to_string(), " ") + buffer.resize(5, 10) + assert_eq(buffer.lines.length, 10) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "abcde") + assert_eq(buffer.lines.get_line(1).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(2).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(3).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(4).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(5).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(6).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(7).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(8).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(9).translate_to_string(), " ") + + + func test_discards_parts_of_wrapped_lines_that_go_out_of_the_scrollback(): + options_service.options.scrollback = 1 + buffer.resize(10, 5) + var last_line = buffer.lines.get_line(3) + for i in range(10): + var code = "a".ord_at(0) + i + var ch = char(code) + last_line.set_cell(i, CellData.from_char_data([0, ch, 1, code])) + assert_eq(buffer.lines.length, 5) + buffer.y = 4 + buffer.resize(2, 5) + assert_eq(buffer.y, 4) + assert_eq(buffer.ybase, 1) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "ab") + assert_eq(buffer.lines.get_line(1).translate_to_string(), "cd") + assert_eq(buffer.lines.get_line(2).translate_to_string(), "ef") + assert_eq(buffer.lines.get_line(3).translate_to_string(), "gh") + assert_eq(buffer.lines.get_line(4).translate_to_string(), "ij") + assert_eq(buffer.lines.get_line(5).translate_to_string(), " ") + buffer.resize(1, 5) + assert_eq(buffer.y, 4) + assert_eq(buffer.ybase, 1) + assert_eq(buffer.lines.length, 6) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "f") + assert_eq(buffer.lines.get_line(1).translate_to_string(), "g") + assert_eq(buffer.lines.get_line(2).translate_to_string(), "h") + assert_eq(buffer.lines.get_line(3).translate_to_string(), "i") + assert_eq(buffer.lines.get_line(4).translate_to_string(), "j") + assert_eq(buffer.lines.get_line(5).translate_to_string(), " ") + buffer.resize(10, 5) + assert_eq(buffer.y, 1) + assert_eq(buffer.ybase, 0) + assert_eq(buffer.lines.length, 5) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "fghij ") + assert_eq(buffer.lines.get_line(1).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(2).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(3).translate_to_string(), " ") + assert_eq(buffer.lines.get_line(4).translate_to_string(), " ") + + + func test_removes_the_correct_amount_of_rows_when_reflowing_larger(): + # This is a regression test to ensure that successive wrapped lines that are getting + # 3+ lines removed on a reflow actually remove the right lines + buffer.resize(10, 10) + buffer.y = 2 + var first_line = buffer.lines.get_line(0) + var second_line = buffer.lines.get_line(1) + for i in range(10): + var code = "a".ord_at(0) + i + var ch = char(code) + first_line.set_cell(i, CellData.from_char_data([0, ch, 1, code])) + for i in range(10): + var code = "0".ord_at(0) + i + var ch = char(code) + second_line.set_cell(i, CellData.from_char_data([0, ch, 1, code])) + assert_eq(buffer.lines.length, 10) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "abcdefghij") + assert_eq(buffer.lines.get_line(1).translate_to_string(), "0123456789") + for i in range(2, 10): + assert_eq(buffer.lines.get_line(i).translate_to_string(), " ") + buffer.resize(2, 10) + assert_eq(buffer.ybase, 1) + assert_eq(buffer.lines.length, 11) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "ab") + assert_eq(buffer.lines.get_line(1).translate_to_string(), "cd") + assert_eq(buffer.lines.get_line(2).translate_to_string(), "ef") + assert_eq(buffer.lines.get_line(3).translate_to_string(), "gh") + assert_eq(buffer.lines.get_line(4).translate_to_string(), "ij") + assert_eq(buffer.lines.get_line(5).translate_to_string(), "01") + assert_eq(buffer.lines.get_line(6).translate_to_string(), "23") + assert_eq(buffer.lines.get_line(7).translate_to_string(), "45") + assert_eq(buffer.lines.get_line(8).translate_to_string(), "67") + assert_eq(buffer.lines.get_line(9).translate_to_string(), "89") + assert_eq(buffer.lines.get_line(10).translate_to_string(), " ") + buffer.resize(10, 10) + assert_eq(buffer.ybase, 0) + assert_eq(buffer.lines.length, 10) + assert_eq(buffer.lines.get_line(0).translate_to_string(), "abcdefghij") + assert_eq(buffer.lines.get_line(1).translate_to_string(), "0123456789") + for i in range(2, 10): + assert_eq(buffer.lines.get_line(i).translate_to_string(), " ", + "line %d is incorrect" % i) diff --git a/test/unit/buffer/test_buffer_line.gd b/test/unit/buffer/test_buffer_line.gd index 5febf35..4807ba8 100644 --- a/test/unit/buffer/test_buffer_line.gd +++ b/test/unit/buffer/test_buffer_line.gd @@ -144,7 +144,6 @@ class TestCellData: func before_each(): cell = CellData.new() - func test_char_data_cell_data_equality(): @@ -242,8 +241,97 @@ class TestBufferLine: [6, 'f', 0, 'f'.ord_at(0)], [5, 'e', 0, 'e'.ord_at(0)], ]) + + + func test_copy_from(): + var line = BufferLineTest.new(5) + line.set_cell(0, CellData.from_char_data([1, 'a', 0, 'a'.ord_at(0)])) + line.set_cell(0, CellData.from_char_data([2, 'b', 0, 'b'.ord_at(0)])) + line.set_cell(0, CellData.from_char_data([3, 'c', 0, 'c'.ord_at(0)])) + line.set_cell(0, CellData.from_char_data([4, 'd', 0, 'd'.ord_at(0)])) + line.set_cell(0, CellData.from_char_data([5, 'e', 0, 'e'.ord_at(0)])) + var line2 = BufferLineTest.new(5, CellData.from_char_data([1, 'a', 0, 'a'.ord_at(0)]), true) + line2.copy_from(line) + assert_eq(line2.to_array(), line.to_array()) + assert_eq(line2.length, line.length) + assert_eq(line2.is_wrapped, line.is_wrapped) + + +class TestResize: + extends "res://addons/gut/test.gd" + + + var CHAR_DATA = [1, 'a', 0, 'a'.ord_at(0)] + var line + + func repeat(el, times: int) -> Array: + var result = [] + result.resize(times) + for i in range(times): + result[i] = el + return result + + + func test_enlarge(): + line = BufferLineTest.new(5, CellData.from_char_data(CHAR_DATA), false) + line.resize(10, CellData.from_char_data(CHAR_DATA)) + assert_eq(line.to_array(), repeat(CHAR_DATA, 10)) + + + func test_shrink(): + line = BufferLineTest.new(10, CellData.from_char_data(CHAR_DATA), false) + line.resize(5, CellData.from_char_data(CHAR_DATA)) + assert_eq(line.to_array(), repeat(CHAR_DATA, 5)) + + + func test_shrink_to_0_length(): + line = BufferLineTest.new(10, CellData.from_char_data(CHAR_DATA), false) + line.resize(0, CellData.from_char_data(CHAR_DATA)) + assert_eq(line.to_array(), repeat(CHAR_DATA, 0)) + + func shrink_then_enlarge(): + line = BufferLineTest.new(10, CellData.from_char_data(CHAR_DATA), false); + line.set_cell(2, CellData.from_char_data([0, '😁', 1, '😁'.ord_at(0)])) + line.set_cell(9, CellData.from_char_data([0, '😁', 1, '😁'.ord_at(0)])) + assert_eq(line.translate_to_string(), 'aa😁aaaaaa😁') + line.resize(5, CellData.from_char_data(CHAR_DATA)) + assert_eq(line.translate_to_string(), 'aa😁aa') + line.resize(10, CellData.from_char_data(CHAR_DATA)) + assert_eq(line.translate_to_string(), 'aa😁aaaaaaa') + + +class TestTrimLength: + extends "res://addons/gut/test.gd" + + + var line + + + func before_each(): + line = BufferLineTest.new(3, CellData.from_char_data([Constants.DEFAULT_ATTR, + Constants.NULL_CELL_CHAR, Constants.NULL_CELL_WIDTH, Constants.NULL_CELL_CODE])) + + + func test_empty_line(): + assert_eq(line.get_trimmed_length(), 0) + + + func test_ascii(): + line.set_cell(0, CellData.from_char_data([1, "a", 1, "a".ord_at(0)])) + line.set_cell(2, CellData.from_char_data([1, "a", 1, "a".ord_at(0)])) + assert_eq(line.get_trimmed_length(), 3) + + + func test_unicode(): + line.set_cell(0, CellData.from_char_data([1, "\u1f914", 1, "\u1f914".ord_at(0)])) + line.set_cell(2, CellData.from_char_data([1, "\u1f914", 1, "\u1f914".ord_at(0)])) + assert_eq(line.get_trimmed_length(), 3) + + + func test_one_cell(): + line.set_cell(0, CellData.from_char_data([1, "a", 1, "a".ord_at(0)])) + assert_eq(line.get_trimmed_length(), 1) -# Skipped a bunch of tests here... class TestAddCharToCell: extends "res://addons/gut/test.gd" @@ -293,7 +381,7 @@ class TestAddCharToCell: assert_eq(cell.is_combined(), Content.IS_COMBINED_MASK) -class Testtranslate_to_string: +class TestTranslateToString: extends "res://addons/gut/test.gd" diff --git a/test/unit/test_circular_list.gd b/test/unit/test_circular_list.gd new file mode 100644 index 0000000..ccca6bd --- /dev/null +++ b/test/unit/test_circular_list.gd @@ -0,0 +1,201 @@ +# Copyright (c) 2016 The xterm.js authors. All rights reserved. +# Ported to GDScript by the GodotXterm authors. +# License MIT +extends "res://addons/gut/test.gd" + + +const CIRCULAR_LIST_PATH = "res://addons/godot_xterm/circular_list.gd" +const CircularList = preload(CIRCULAR_LIST_PATH) + +var list + + +func before_each(): + list = CircularList.new(5) + + +func test_push(): + list.push("1") + list.push("2") + list.push("3") + list.push("4") + list.push("5") + assert_eq(list.get_line(0), "1") + assert_eq(list.get_line(1), "2") + assert_eq(list.get_line(2), "3") + assert_eq(list.get_line(3), "4") + assert_eq(list.get_line(4), "5") + + +func test_splice_deletes_items(): + list = CircularList.new(2) + list.push("1") + list.push("2") + list.splice(0, 1) + assert_eq(list.length, 1) + assert_eq(list.get_el(0), "2") + list.push("3") + list.splice(1, 1) + assert_eq(list.length, 1) + assert_eq(list.get_el(0), "2") + + +func test_splice_inserts_items(): + list = CircularList.new(2) + list.push("1") + list.splice(0, 0, ["2"]) + assert_eq(list.length, 2) + assert_eq(list.get_el(0), "2") + assert_eq(list.get_el(1), "1") + list.splice(1, 0, ["3"]) + assert_eq(list.length, 2) + assert_eq(list.get_el(0), "3") + assert_eq(list.get_el(1), "1") + + +func test_splice_deletes_items_then_inserts_items(): + list = CircularList.new(3) + list.push("1") + list.push("2") + list.splice(0, 1, ["3", "4"]) + assert_eq(list.length, 3) + assert_eq(list.get_el(0), "3") + assert_eq(list.get_el(1), "4") + assert_eq(list.get_el(2), "2") + + +func test_splice_wraps_the_array_correctly_when_more_items_are_inserted_than_deleted(): + list = CircularList.new(3) + list.push("1") + list.push("2") + list.splice(1, 0, ["3", "4"]) + assert_eq(list.length, 3) + assert_eq(list.get_el(0), "3") + assert_eq(list.get_el(1), "4") + assert_eq(list.get_el(2), "2") + + +class TestShiftElements: + extends "res://addons/gut/test.gd" + + + var list + + + func before_each(): + list = CircularList.new(5) + + + func test_does_not_mutate_the_list_when_count_is_0(): + list.push(1) + list.push(2) + list.shift_elements(0, 0, 1) + assert_eq(list.length, 2) + assert_eq(list.get_el(0), 1) + assert_eq(list.get_el(1), 2) + + + func test_pushes_errors_for_invalid_args(): + list = partial_double(CIRCULAR_LIST_PATH).new() + list.max_length = 5 + list.push(1) + list.shift_elements(-1, 1, 1) + assert_called(list, "push_error", ["start argument out of range"]) + list.shift_elements(1, 1, 1) + assert_called(list, "push_error", ["start argument out of range"]) + list.shift_elements(0, 1, -1) + assert_called(list, "push_error", ["cannot shift elements in list beyond index 0"]) + + + func test_trim_start_removes_items_from_the_beginning_of_the_list(): + list.push("1") + list.push("2") + list.push("3") + list.push("4") + list.push("5") + list.trim_start(1) + assert_eq(list.length, 4) + assert_eq(list.get_el(0), "2") + assert_eq(list.get_el(1), "3") + assert_eq(list.get_el(2), "4") + assert_eq(list.get_el(3), "5") + list.trim_start(2) + assert_eq(list.length, 2) + assert_eq(list.get_el(0), "4") + assert_eq(list.get_el(1), "5") + + + func test_trim_start_removes_all_items_if_the_requested_trim_amount_is_larger_than_the_lists_length(): + list.push("1") + list.trim_start(2) + assert_eq(list.length, 0) + + + func test_shifts_an_element_forward(): + list.push(1) + list.push(2) + list.shift_elements(0, 1, 1) + assert_eq(list.length, 2) + assert_eq(list.get_el(0), 1) + assert_eq(list.get_el(1), 1) + + + func test_shifts_elements_forward(): + list.push(1) + list.push(2) + list.push(3) + list.push(4) + list.shift_elements(0, 2, 2) + assert_eq(list.length, 4) + assert_eq(list.get_el(0), 1) + assert_eq(list.get_el(1), 2) + assert_eq(list.get_el(2), 1) + assert_eq(list.get_el(3), 2) + + + func test_shifts_elements_forward_expanding_the_list_if_needed(): + list.push(1) + list.push(2) + list.shift_elements(0, 2, 2) + assert_eq(list.length, 4) + assert_eq(list.get_el(0), 1) + assert_eq(list.get_el(1), 2) + assert_eq(list.get_el(2), 1) + assert_eq(list.get_el(3), 2) + + + func test_shifts_elements_forward_wrapping_the_list_if_needed(): + list.push(1) + list.push(2) + list.push(3) + list.push(4) + list.push(5) + list.shift_elements(2, 2, 3) + assert_eq(list.length, 5) + assert_eq(list.get_el(0), 3) + assert_eq(list.get_el(1), 4) + assert_eq(list.get_el(2), 5) + assert_eq(list.get_el(3), 3) + assert_eq(list.get_el(4), 4) + + + func test_shifts_an_element_backwards(): + list.push(1) + list.push(2) + list.shift_elements(1, 1, -1) + assert_eq(list.length, 2) + assert_eq(list.get_el(0), 2) + assert_eq(list.get_el(1), 2) + + + func test_shiftS_elements_backwards(): + list.push(1) + list.push(2) + list.push(3) + list.push(4) + list.shift_elements(2, 2, -2) + assert_eq(list.length, 4) + assert_eq(list.get_el(0), 3) + assert_eq(list.get_el(1), 4) + assert_eq(list.get_el(2), 3) + assert_eq(list.get_el(3), 4) diff --git a/test/unit/test_input_handler.gd b/test/unit/test_input_handler.gd index fff1e21..a6b3183 100644 --- a/test/unit/test_input_handler.gd +++ b/test/unit/test_input_handler.gd @@ -9,6 +9,10 @@ const InputHandler = preload("res://addons/godot_xterm/input_handler.gd") const CharsetService = preload("res://addons/godot_xterm/services/charset_service.gd") const Params = preload("res://addons/godot_xterm/parser/params.gd") const CoreService = preload("res://addons/godot_xterm/services/core_service.gd") +const Constants = preload("res://addons/godot_xterm/buffer/constants.gd") +const OptionsService = preload("res://addons/godot_xterm/services/options_service.gd") + +const CursorStyle = Constants.CursorStyle var options_service var buffer_service @@ -49,12 +53,76 @@ func before_each(): charset_service = CharsetService.new() core_service = CoreService.new() input_handler = InputHandler.new(buffer_service, core_service, charset_service, options_service) + + +func test_save_and_restore_cursor(): + buffer_service.buffer.x = 1 + buffer_service.buffer.y = 2 + buffer_service.buffer.ybase = 0 + input_handler._cur_attr_data.fg = 3 + # Save cursor position + input_handler.save_cursor() + assert_eq(buffer_service.buffer.x, 1) + assert_eq(buffer_service.buffer.y, 2) + assert_eq(input_handler._cur_attr_data.fg, 3) + # Change the cursor position + buffer_service.buffer.x = 10 + buffer_service.buffer.y = 20 + input_handler._cur_attr_data.fg = 30 + # Restore cursor position + input_handler.restore_cursor() + assert_eq(buffer_service.buffer.x, 1) + assert_eq(buffer_service.buffer.y, 2) + assert_eq(input_handler._cur_attr_data.fg, 3) -# Skipping lots of tests here... + +func test_set_cursor_style(): + input_handler.set_cursor_style(Params.from_array([0])) + assert_eq(options_service.options.cursor_style, CursorStyle.BLOCK) + assert_eq(options_service.options.cursor_blink, true) + + options_service.options = OptionsService.TerminalOptions.new() + input_handler.set_cursor_style(Params.from_array([1])) + assert_eq(options_service.options.cursor_style, CursorStyle.BLOCK) + assert_eq(options_service.options.cursor_blink, true) + + options_service.options = OptionsService.TerminalOptions.new() + input_handler.set_cursor_style(Params.from_array([2])) + assert_eq(options_service.options.cursor_style, CursorStyle.BLOCK) + assert_eq(options_service.options.cursor_blink, false) + + options_service.options = OptionsService.TerminalOptions.new() + input_handler.set_cursor_style(Params.from_array([3])) + assert_eq(options_service.options.cursor_style, CursorStyle.UNDERLINE) + assert_eq(options_service.options.cursor_blink, true) + + options_service.options = OptionsService.TerminalOptions.new() + input_handler.set_cursor_style(Params.from_array([4])) + assert_eq(options_service.options.cursor_style, CursorStyle.UNDERLINE) + assert_eq(options_service.options.cursor_blink, false) + + options_service.options = OptionsService.TerminalOptions.new() + input_handler.set_cursor_style(Params.from_array([5])) + assert_eq(options_service.options.cursor_style, CursorStyle.BAR) + assert_eq(options_service.options.cursor_blink, true) + + options_service.options = OptionsService.TerminalOptions.new() + input_handler.set_cursor_style(Params.from_array([6])) + assert_eq(options_service.options.cursor_style, CursorStyle.BAR) + assert_eq(options_service.options.cursor_blink, false) + + +func test_set_mode_toggles_bracketed_paste_mode(): + # Set bracketed paste mode + input_handler.set_mode_private(Params.from_array([2004])) + assert_true(core_service.dec_private_modes.bracketed_paste_mode) + # Reset bracketed paste mode + input_handler.reset_mode_private(Params.from_array([2004])) + assert_false(core_service.dec_private_modes.bracketed_paste_mode) func test_erase_in_line(): - buffer_service = TestUtils.MockBufferService.new(10, 3, options_service) + buffer_service = TestUtils.MockBufferService.new(80, 30, options_service) input_handler = InputHandler.new(buffer_service, core_service, charset_service, options_service) # fill 6 lines to test 3 different states @@ -65,27 +133,27 @@ func test_erase_in_line(): # params[0] - right erase buffer_service.buffer.y = 0 - buffer_service.buffer.x = 7 + buffer_service.buffer.x = 70 input_handler.erase_in_line(Params.from_array([0])) - assert_eq(buffer_service.buffer.lines.get_el(0).translate_to_string(false), - repeat("a", 7) + " ") + assert_eq(buffer_service.buffer.lines.get_line(0).translate_to_string(false), + repeat("a", 70) + " ") -# # params[1] - left erase -# buffer_service.buffer.y = 1 -# buffer_service.buffer.x = 70 -# input_handler.erase_in_line(Params.from_array([1])) -# assert_eq(buffer_service.buffer.lines.get_el(1).translate_to_string(false), -# repeat(" ", 70) + " aaaaaaaaa") -# -# # params[1] - left erase -# buffer_service.buffer.y = 2 -# buffer_service.buffer.x = 70 -# input_handler.erase_in_line(Params.from_array([2])) -# assert_eq(buffer_service.buffer.lines.get_el(2).translate_to_string(false), -# repeat(" ", buffer_service.cols)) + # params[1] - left erase + buffer_service.buffer.y = 1 + buffer_service.buffer.x = 70 + input_handler.erase_in_line(Params.from_array([1])) + assert_eq(buffer_service.buffer.lines.get_line(1).translate_to_string(false), + repeat(" ", 70) + " aaaaaaaaa") + + # params[1] - left erase + buffer_service.buffer.y = 2 + buffer_service.buffer.x = 70 + input_handler.erase_in_line(Params.from_array([2])) + assert_eq(buffer_service.buffer.lines.get_line(2).translate_to_string(false), + repeat(" ", buffer_service.cols)) -func skip_test_erase_in_display(): +func test_erase_in_display(): buffer_service = TestUtils.MockBufferService.new(80, 7, options_service) input_handler = InputHandler.new(buffer_service, core_service, charset_service, options_service) @@ -119,7 +187,7 @@ func skip_test_erase_in_display(): # reset for _i in range(buffer_service.rows): input_handler.parse(repeat("a", buffer_service.cols)) - + # params [1] - left and above buffer_service.buffer.y = 5; buffer_service.buffer.x = 40; @@ -169,36 +237,38 @@ func skip_test_erase_in_display(): "", "", ])) -# -# # reset and add a wrapped line -# buffer_service.buffer.y = 0; -# buffer_service.buffer.x = 0; -# input_handler.parse(Array(buffer_service.cols + 1).join('a')); # line 0 -# input_handler.parse(Array(buffer_service.cols + 10).join('a')); # line 1 and 2 -# for (let i = 3; i < buffer_service.rows; ++i) input_handler.parse(Array(buffer_service.cols + 1).join('a')); -# -# # params[1] left and above with wrap -# # confirm precondition that line 2 is wrapped -# expect(buffer_service.buffer.lines.get(2)!.isWrapped).true; -# buffer_service.buffer.y = 2; -# buffer_service.buffer.x = 40; -# input_handler.erase_in_display(Params.from_array([1])); -# expect(buffer_service.buffer.lines.get(2)!.isWrapped).false; -# -# # reset and add a wrapped line -# buffer_service.buffer.y = 0; -# buffer_service.buffer.x = 0; -# input_handler.parse(Array(buffer_service.cols + 1).join('a')); # line 0 -# input_handler.parse(Array(buffer_service.cols + 10).join('a')); # line 1 and 2 -# for (let i = 3; i < buffer_service.rows; ++i) input_handler.parse(Array(buffer_service.cols + 1).join('a')); -# -# # params[1] left and above with wrap -# # confirm precondition that line 2 is wrapped -# expect(buffer_service.buffer.lines.get(2)!.isWrapped).true; -# buffer_service.buffer.y = 1; -# buffer_service.buffer.x = 90; # Cursor is beyond last column -# input_handler.erase_in_display(Params.from_array([1])); -# expect(buffer_service.buffer.lines.get(2)!.isWrapped).false; + + # reset and add a wrapped line + buffer_service.buffer.y = 0; + buffer_service.buffer.x = 0; + input_handler.parse(repeat("a", buffer_service.cols)) # line 0 + input_handler.parse(repeat("a", buffer_service.cols + 9)) # line 1 and 2 + for i in range(3, buffer_service.rows): + input_handler.parse(repeat("a", buffer_service.cols)) + + # params[1] left and above with wrap + # confirm precondition that line 2 is wrapped + assert_true(buffer_service.buffer.lines.get_line(2).is_wrapped) + buffer_service.buffer.y = 2 + buffer_service.buffer.x = 40 + input_handler.erase_in_display(Params.from_array([1])) + assert_false(buffer_service.buffer.lines.get_line(2).is_wrapped) + + # reset and add a wrapped line + buffer_service.buffer.y = 0 + buffer_service.buffer.x = 0 + input_handler.parse(repeat("a", buffer_service.cols)) # line 0 + input_handler.parse(repeat("a", buffer_service.cols + 9)) # line 1 and 2 + for i in range(3, buffer_service.rows): + input_handler.parse(repeat("a", buffer_service.cols)) + + # params[1] left and above with wrap + # confirm precondition that line 2 is wrapped + assert_true(buffer_service.buffer.lines.get_line(2).is_wrapped) + buffer_service.buffer.y = 1 + buffer_service.buffer.x = 90 # Cursor is beyond last column + input_handler.erase_in_display(Params.from_array([1])); + assert_false(buffer_service.buffer.lines.get_line(2).is_wrapped) func test_print_does_not_cause_an_infinite_loop(): diff --git a/test/unit/test_params.gd b/test/unit/test_params.gd index c3987cc..d80cf6b 100644 --- a/test/unit/test_params.gd +++ b/test/unit/test_params.gd @@ -3,22 +3,27 @@ # License MIT extends 'res://addons/gut/test.gd' + const Params = preload("res://addons/godot_xterm/parser/params.gd") + class TestParams: extends 'res://addons/gut/test.gd' var params + func before_each(): params = Params.new() + func test_respects_ctor_args(): params = Params.new(12, 23) assert_eq(params.params.size(), 12) assert_eq(params.sub_params.size(), 23) assert_eq(params.to_array(), []) + func test_add_param(): params.add_param(1) assert_eq(params.length, 1) @@ -30,6 +35,7 @@ class TestParams: assert_eq(params.to_array(), [1, 23]) assert_eq(params.sub_params_length, 0) + func test_add_sub_param(): params.add_param(1) params.add_sub_param(2) @@ -43,6 +49,7 @@ class TestParams: assert_eq(params.sub_params_length, 3) assert_eq(params.to_array(), [1, [2,3], 12345, [-1]]) + func test_should_not_add_sub_params_without_previous_param(): params.add_sub_param(2) params.add_sub_param(3) @@ -56,6 +63,7 @@ class TestParams: assert_eq(params.sub_params_length, 2) assert_eq(params.to_array(), [1, [2, 3]]) + func test_reset(): params.add_param(1) params.add_sub_param(2) @@ -92,13 +100,25 @@ class TestParams: # ignore leading sub params data = [[1,2], 12345, [-1]] assert_eq(Params.from_array(data).to_array(), [12345, [-1]]) + + + func test_has_sub_params_get_sub_params(): + params = Params.from_array([38, [2, 50, 100, 150], 5, [], 6]) + assert_eq(params.has_sub_params(0), true) + assert_eq(params.get_sub_params(0), [2, 50, 100, 150]) + assert_eq(params.has_sub_params(1), false) + assert_eq(params.get_sub_params(1), null) + assert_eq(params.has_sub_params(2), false) + assert_eq(params.get_sub_params(2), null) class TestParse: extends 'res://addons/gut/test.gd' + var params + func parse(params, s): params.reset() params.add_param(0) @@ -124,18 +144,22 @@ class TestParse: i-=1 # End for i+=1 - + + func before_each(): params = Params.new() - + + func test_param_defaults_to_0(): # ZDM (Zero Default Mode) parse(params, '') assert_eq(params.to_array(), [0]) - + + func test_sub_param_defaults_to_neg_1(): parse(params, ':') assert_eq(params.to_array(), [0, [-1]]) - + + func test_reset_on_new_sequence(): parse(params, '1;2;3') assert_eq(params.to_array(), [1, 2, 3]) @@ -145,7 +169,8 @@ class TestParse: assert_eq(params.to_array(), [4, [-1, 123, 5], 6, 7]) parse(params, '') assert_eq(params.to_array(), [0]) - + + func test_should_handle_length_restrictions_correctly(): params = Params.new(3, 3) parse(params, '1;2;3') @@ -162,7 +187,8 @@ class TestParse: # overlong sub params parse(params, '4;38:2::50:100:150;48:5:22') assert_eq(params.to_array(), [4, 38, [2, -1, 50], 48]) - + + func test_typical_sequences(): # SGR with semicolon syntax parse(params, '0;4;38;2;50;100;150;48;5;22') @@ -173,15 +199,18 @@ class TestParse: # SGR colon style parse(params, '0;4;38:2::50:100:150;48:5:22') assert_eq(params.to_array(), [0, 4, 38, [2, -1, 50, 100, 150], 48, [5, 22]]) - + + func test_clamp_parsed_params(): parse(params, '2147483648') assert_eq(params.to_array(), [0x7FFFFFFF]) - + + func test_clamp_parsed_sub_params(): parse(params, ':2147483648') assert_eq(params.to_array(), [0, [0x7FFFFFFF]]) - + + func test_should_cancel_subdigits_if_beyond_params_limit(): parse(params, ';;;;;;;;;10;;;;;;;;;;20;;;;;;;;;;30;31;32;33;34;35::::::::') assert_eq(params.to_array(), [ @@ -190,13 +219,4 @@ class TestParse: 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 31, 32 ]) -# func test_should_carry_forward_is_sub_state(): -# parse(params, ['1:22:33', '44']) -# assert_eq(params.to_array(), [1, [22, 3344]]) - - - - - - - +