diff --git a/CHANGELOG.md b/CHANGELOG.md index ca57952..ae74f67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,20 @@ -# v0.1.1 - 28 September 2026 +# Unreleased + +* Added `Input::RubyText` - a syntax-highlighted Ruby input control + * Subclasses `Text` with minimal code changes + * Uses `dr-parser-rb` for tokenization + * Default theme with customizable colors + * Exposes `tokens` for displaying parse statistics + * Caches parse results using `@value_changed` flag +* Fixed bug where `value=` setter did not set `@value_changed = true` +* Updated console to use `RubyText` for syntax-highlighted prompt +* Added tests for `@value_changed` flag behavior + +# v0.1.1 - 28 September 2025 * Removed a left over debugging `puts`. (Also allows me to test `Input::DEVELOPMENT`) -# v0.1.0 - 28 September 2026 +# v0.1.0 - 28 September 2025 * Updated release workflow to use maintained actions. * Removed references to $clipboard. diff --git a/app/main.rb b/app/main.rb index 6bf1da5..e2623ba 100644 --- a/app/main.rb +++ b/app/main.rb @@ -2,3 +2,4 @@ require 'app/book_sample.rb' # require 'app/log_sample.rb' # require 'app/menu_sample.rb' +# require 'app/ruby_text_sample.rb' diff --git a/app/ruby_text_sample.rb b/app/ruby_text_sample.rb new file mode 100644 index 0000000..612924a --- /dev/null +++ b/app/ruby_text_sample.rb @@ -0,0 +1,105 @@ +require 'lib/input.rb' + +def tick(args) + if args.tick_count == 0 + Input.replace_console! + + args.state.ruby_code ||= 'class Foo; def bar!(x); $gtk.args.outputs.labels << "hello #{x}"; end; end' + + args.state.ruby_input = Input::RubyText.new( + x: 40, + y: 560, + w: 1200, + padding: 10, + prompt: 'Enter Ruby code...', + value: args.state.ruby_code, + size_px: 20, + selection_color: { r: 80, g: 80, b: 100 }, + cursor_color: 0xAAAAAA, + cursor_width: 2, + background_color: [40, 44, 52], + blurred_background_color: [50, 54, 62] + ) + args.state.ruby_input.focus + end + + # Set background color (dark theme) + args.outputs.background_color = [30, 34, 42] + + # Title + args.outputs.labels << { + x: 640, + y: 680, + text: 'Ruby Syntax Highlighter Input Demo', + size_px: 30, + alignment_enum: 1, + r: 200, g: 200, b: 200 + } + + # Instructions + args.outputs.labels << { + x: 640, + y: 640, + text: 'Type or edit Ruby code to see syntax highlighting in real-time', + size_px: 16, + alignment_enum: 1, + r: 150, g: 150, b: 150 + } + + args.state.ruby_input.tick + args.outputs.primitives << args.state.ruby_input + + # Token statistics + y = 540 + args.outputs.labels << { + x: 40, + y: y, + text: 'Token Breakdown:', + size_px: 16, + r: 200, g: 200, b: 200 + } + + y -= 25 + token_types = args.state.ruby_input.tokens.map { |t| t[:type] }.uniq + token_types.each do |type| + count = args.state.ruby_input.tokens.count { |t| t[:type] == type } + + args.outputs.labels << { + x: 40, + y: y, + text: "#{type}: #{count}", + size_px: 12, + r: 150, g: 150, b: 150 + } + y -= 20 + end + + # Info text + bottom_y = 100 + args.outputs.labels << { + x: 40, + y: bottom_y + 20, + text: "Input supports: keywords, strings, symbols, numbers, constants, comments, interpolation, and more!", + size_px: 14, + r: 150, g: 150, b: 150 + } + + # Example code suggestions + args.outputs.labels << { + x: 40, + y: bottom_y, + text: 'Try typing: def foo(bar) @baz = "hello #{bar}" end', + size_px: 12, + r: 120, g: 120, b: 120 + } + + # Footer + args.outputs.labels << { + x: 640, + y: 40, + text: "Press ESC to quit | FPS: #{args.gtk.current_framerate.to_i}", + size_px: 16, + alignment_enum: 1, + r: 150, g: 150, b: 150 + } +end diff --git a/lib/base.rb b/lib/base.rb index 5ecbdf4..dc09c17 100644 --- a/lib/base.rb +++ b/lib/base.rb @@ -153,6 +153,7 @@ def value=(text) @value.replace(val) @selection_start = @selection_start.lesser(val.length) @selection_end = @selection_end.lesser(val.length) + @value_changed = true end def size_enum diff --git a/lib/console.rb b/lib/console.rb index d0cf1dd..e7ae1d1 100644 --- a/lib/console.rb +++ b/lib/console.rb @@ -3,7 +3,7 @@ def self.replace_console! GTK::Console.prepend(Input::Console) end - class Prompt < Text + class Prompt < RubyText def render(args, x:, y:) @x = x @y = y @@ -53,7 +53,11 @@ def process_inputs args # is the cursor before the period? if @autocomplete_period_index && @autocomplete_period_index >= @prompt.selection_end autocomplete_clear - elsif @prompt.value_changed? || @prompt.selection_end != @autocomplete_selection_end + elsif @prompt.value_changed? + # User typed - just update the filter, don't modify their input + autocomplete_prefix + elsif @prompt.selection_end != @autocomplete_selection_end + # Cursor moved (arrow keys) - update suggestion autocomplete_prefix autocomplete_next(0) end @@ -151,9 +155,11 @@ def autocomplete autocomplete_prefix autocomplete_next(0) + @autocomplete_selection_end = @prompt.selection_end # Track the selection we just set + @prompt.instance_variable_set(:@value_changed, false) # Reset flag so next tick doesn't think user typed rescue Exception => e - puts "* BUG: Tab autocompletion failed. Let us know about this.\n#{e}" - puts e.backtrace + puts "* BUG: Tab autocompletion failed: #{e.class.name}: #{e.message}" + puts e.backtrace.join("\n") end def autocomplete_prefix @@ -174,10 +180,20 @@ def autocomplete_prefix def autocomplete_next(dir) return unless @autocompleting + return if @autocomplete_menu.items.empty? @autocomplete_menu.selected_index -= dir - @prompt.value = display_autocomplete_candidate(@autocomplete_menu.value) - @prompt.selection_start = @prompt.value.length + candidate_value = @autocomplete_menu.value + return unless candidate_value + + candidate = display_autocomplete_candidate(candidate_value) + @prompt.value = candidate + # Set selection to highlight the autocompleted suffix + selection_pos = @autocomplete_period_index ? @autocomplete_period_index + 1 + @autocomplete_prefix.length : @autocomplete_prefix.length + @prompt.selection_start = selection_pos + @prompt.selection_end = candidate.length + @autocomplete_selection_end = @prompt.selection_end # Track the selection we just set + @prompt.instance_variable_set(:@value_changed, false) # Reset flag so next tick doesn't think user typed rescue => e puts "Error in #autocomplete_next(#{dir}): <#{e.class.name}> #{e.message}" end diff --git a/lib/input.rb b/lib/input.rb index c294be2..65c3a22 100644 --- a/lib/input.rb +++ b/lib/input.rb @@ -7,6 +7,7 @@ require_relative 'base.rb' require_relative 'update.rb' require_relative 'text.rb' +require_relative 'ruby_text.rb' require_relative 'multiline.rb' require_relative 'menu.rb' require_relative 'console.rb' diff --git a/lib/parser.rb b/lib/parser.rb new file mode 100644 index 0000000..a0abefa --- /dev/null +++ b/lib/parser.rb @@ -0,0 +1,376 @@ +# dr-parse-rb 0.0.1 +# MIT Licensed +# Copyright (c) 2025 Marc Heiligers +# See https://github.com/marcheiligers/dr-parse-rb + +class RubyParser + OPERATORS = [ + '=>', '==', '!=', '<=', '>=', '<<', '>>', '&&', '||', + '+', '-', '*', '/', '%', '=', '<', '>', '!', '&', '|', '^', '~', + '(', ')', '[', ']', '{', '}', ',', '.', ':', ';', '?' + ] + + KEYWORDS = [ + 'alias', 'and', 'begin', 'break', 'case', 'class', 'def', 'defined?', + 'do', 'else', 'elsif', 'end', 'ensure', 'false', 'for', 'if', 'in', + 'module', 'next', 'nil', 'not', 'or', 'redo', 'rescue', 'retry', + 'return', 'self', 'super', 'then', 'true', 'undef', 'unless', 'until', + 'when', 'while', 'yield' + ] + + def initialize(input) + @input = input + @pos = 0 + @tokens = [] + end + + def parse + return [] if @input.nil? || @input.empty? + + while @pos < @input.length + char = @input[@pos] + + if whitespace?(char) + parse_whitespace + elsif digit?(char) + parse_number + elsif char == '#' + parse_comment + elsif char == '"' + parse_string_double + elsif char == "'" + parse_string_single + elsif char == '%' + parse_percent_literal + elsif char == '$' + parse_global + elsif char == ':' + # Check if this is :: (scope operator), part of namespace, hash syntax, or a symbol + if @pos + 1 < @input.length + next_char = @input[@pos + 1] + if next_char == ':' || (next_char >= 'A' && next_char <= 'Z') || whitespace?(next_char) || next_char == '}' + # :: or :Constant or hash syntax (a: value) - treat as operator + parse_operator + else + parse_symbol + end + else + parse_operator + end + elsif operator_start?(char) + parse_operator + elsif identifier_start?(char) + parse_identifier + else + @pos += 1 + end + end + + @tokens + end + + private + + def whitespace?(char) + char == ' ' || char == "\t" || char == "\n" || char == "\r" + end + + def digit?(char) + char >= '0' && char <= '9' + end + + def identifier_start?(char) + (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + char == '_' + end + + def identifier_char?(char) + identifier_start?(char) || digit?(char) || char == '?' || char == '!' + end + + def operator_start?(char) + OPERATORS.any? { |op| op[0] == char } + end + + def parse_whitespace + start_pos = @pos + while @pos < @input.length && whitespace?(@input[@pos]) + @pos += 1 + end + add_token(:whitespace, start_pos, @pos - 1) + end + + def parse_number + start_pos = @pos + + # Parse integer or decimal part with optional underscores + while @pos < @input.length + char = @input[@pos] + if digit?(char) + @pos += 1 + elsif char == '_' && @pos + 1 < @input.length && digit?(@input[@pos + 1]) + # Allow underscore only if followed by a digit + @pos += 1 + elsif char == '.' && @pos + 1 < @input.length && digit?(@input[@pos + 1]) + # Allow decimal point only if followed by a digit + @pos += 1 + else + break + end + end + + add_token(:number, start_pos, @pos - 1) + end + + def parse_comment + start_pos = @pos + while @pos < @input.length && @input[@pos] != "\n" + @pos += 1 + end + add_token(:comment, start_pos, @pos - 1) + end + + def parse_string_double + string_start = @pos + @pos += 1 # skip opening quote + + while @pos < @input.length + char = @input[@pos] + + if char == '\\' + # Check for escaped interpolation + if @pos + 1 < @input.length && @input[@pos + 1] == '#' + @pos += 2 # skip \# + else + @pos += 2 # skip other escape sequence + end + elsif char == '#' && @pos + 1 < @input.length && @input[@pos + 1] == '{' + # Found interpolation - emit string token up to here + if @pos > string_start + add_token(:string, string_start, @pos - 1) + end + + # Parse interpolation + parse_interpolation + + # Continue with next string segment + string_start = @pos + elsif char == '"' + # Emit final string token including closing quote + add_token(:string, string_start, @pos) + @pos += 1 + return + else + @pos += 1 + end + end + + # Incomplete string - emit what we have + add_token(:string, string_start, @pos - 1) if @pos > string_start + end + + def parse_interpolation + # Emit interpolation start token + interp_start = @pos + @pos += 2 # skip #{ + add_token(:interpolation_start, interp_start, @pos - 1) + + # Track brace depth to handle nested braces + brace_depth = 1 + + # Parse tokens inside interpolation + while @pos < @input.length && brace_depth > 0 + char = @input[@pos] + + if char == '{' + brace_depth += 1 + parse_operator + elsif char == '}' + brace_depth -= 1 + if brace_depth == 0 + # Emit interpolation end token + add_token(:interpolation_end, @pos, @pos) + @pos += 1 + else + parse_operator + end + elsif whitespace?(char) + parse_whitespace + elsif digit?(char) + parse_number + elsif char == '#' + parse_comment + elsif char == '"' + parse_string_double + elsif char == "'" + parse_string_single + elsif char == ':' + # Check if this is :: (scope operator), part of namespace, hash syntax, or a symbol + if @pos + 1 < @input.length + next_char = @input[@pos + 1] + if next_char == ':' || (next_char >= 'A' && next_char <= 'Z') || whitespace?(next_char) || next_char == '}' + # :: or :Constant or hash syntax (a: value) - treat as operator + parse_operator + else + parse_symbol + end + else + parse_operator + end + elsif operator_start?(char) + parse_operator + elsif identifier_start?(char) + parse_identifier + else + @pos += 1 + end + end + end + + def parse_string_single + start_pos = @pos + @pos += 1 # skip opening quote + + while @pos < @input.length + char = @input[@pos] + if char == '\\' + @pos += 2 # skip escape sequence + elsif char == "'" + @pos += 1 # include closing quote + break + else + @pos += 1 + end + end + + add_token(:string, start_pos, @pos - 1) + end + + def parse_symbol + start_pos = @pos + @pos += 1 # skip ':' + + # Symbol can be quoted or identifier-like + if @pos < @input.length + if @input[@pos] == '"' || @input[@pos] == "'" + # Quoted symbol like :"foo bar" + quote = @input[@pos] + @pos += 1 + @pos += 1 while @pos < @input.length && @input[@pos] != quote + @pos += 1 if @pos < @input.length # skip closing quote + else + # Regular symbol like :foo + @pos += 1 while @pos < @input.length && identifier_char?(@input[@pos]) + end + end + + add_token(:symbol, start_pos, @pos - 1) + end + + def parse_operator + start_pos = @pos + + # Try to match multi-character operators first + if @pos + 1 < @input.length + two_char = @input[@pos..@pos + 1] + if OPERATORS.include?(two_char) + @pos += 2 + add_token(:operator, start_pos, @pos - 1) + return + end + end + + # Single character operator + @pos += 1 + add_token(:operator, start_pos, @pos - 1) + end + + def parse_identifier + start_pos = @pos + first_char = @input[@pos] + + @pos += 1 while @pos < @input.length && identifier_char?(@input[@pos]) + + value = @input[start_pos..@pos - 1] + + # Determine token type: constant (starts with uppercase), keyword, or identifier + token_type = if first_char >= 'A' && first_char <= 'Z' + :constant + elsif KEYWORDS.include?(value) + :keyword + else + :identifier + end + + add_token(token_type, start_pos, @pos - 1) + end + + def parse_global + start_pos = @pos + @pos += 1 # skip $ + + # Special globals like $$, $!, $?, $0-$9, etc. + if @pos < @input.length + char = @input[@pos] + if char == '$' || char == '!' || char == '?' || char == '&' || char == '`' || + char == "'" || char == '+' || char == '~' || char == '=' || char == '/' || + char == '\\' || char == ',' || char == ';' || char == '.' || char == '<' || + char == '>' || char == '*' || char == '@' || char == ':' || + (char >= '0' && char <= '9') + @pos += 1 + elsif identifier_start?(char) + # Regular global like $gtk, $my_var + while @pos < @input.length && identifier_char?(@input[@pos]) + @pos += 1 + end + end + end + + add_token(:global, start_pos, @pos - 1) + end + + def parse_percent_literal + start_pos = @pos + @pos += 1 # skip % + + return parse_operator if @pos >= @input.length + + # Get the type character (w, W, i, I, q, Q, r, s, x, etc.) + type_char = @input[@pos] # TODO: use type_char + @pos += 1 + + return parse_operator if @pos >= @input.length + + # Get the delimiter + delimiter = @input[@pos] + closing_delimiter = case delimiter + when '[' then ']' + when '(' then ')' + when '{' then '}' + when '<' then '>' + else delimiter + end + + @pos += 1 # skip opening delimiter + + # Find the closing delimiter + while @pos < @input.length + if @input[@pos] == '\\' + @pos += 2 # skip escape sequence + elsif @input[@pos] == closing_delimiter + @pos += 1 # include closing delimiter + break + else + @pos += 1 + end + end + + add_token(:array_literal, start_pos, @pos - 1) + end + + def add_token(type, start_pos, end_pos) + value = @input[start_pos..end_pos] + @tokens << { type: type, value: value, start: start_pos, end: end_pos } + end +end diff --git a/lib/ruby_text.rb b/lib/ruby_text.rb new file mode 100644 index 0000000..8402064 --- /dev/null +++ b/lib/ruby_text.rb @@ -0,0 +1,75 @@ +require 'lib/parser.rb' + +module Input + class RubyText < Text + attr_reader :tokens + + DEFAULT_THEME = { + keyword: { r: 197, g: 134, b: 192 }, # Purple + string: { r: 152, g: 195, b: 121 }, # Green + number: { r: 209, g: 154, b: 102 }, # Orange + comment: { r: 92, g: 99, b: 112 }, # Gray + symbol: { r: 86, g: 182, b: 194 }, # Cyan + constant: { r: 229, g: 192, b: 123 }, # Yellow + identifier: { r: 224, g: 224, b: 224 }, # White + operator: { r: 171, g: 178, b: 191 }, # Light gray + global: { r: 224, g: 108, b: 117 }, # Red + interpolation_start: { r: 152, g: 195, b: 121 }, # Green + interpolation_end: { r: 152, g: 195, b: 121 }, # Green + array_literal: { r: 152, g: 195, b: 121 } # Green + }.freeze + + def initialize(**params) + custom_theme = params.delete(:theme) || {} + theme = DEFAULT_THEME.merge(custom_theme) + @theme = theme.transform_values { |color| color.merge(vertical_alignment_enum: 0) } + @tokens = [] + super + end + + def render_text(rt) + # Parse only if value has changed or tokens are empty + if @value_changed || @tokens.empty? + parser = RubyParser.new(@value.to_s) + @tokens = parser.parse + end + + # Find visible range + f = find_index_at_x(@scroll_x) + l = find_index_at_x(@scroll_x + @content_w) + 2 + + # Calculate character positions and render visible tokens + char_index = 0 + + @tokens.each do |token| + token_length = token[:value].length + token_start = char_index + token_end = char_index + token_length + + # Check if token is in visible range + if token_end > f && token_start < l + # Calculate visible portion of token + visible_start = [token_start, f].max + visible_end = [token_end, l].min + visible_text = token[:value][visible_start - token_start, visible_end - visible_start] + + # Get color from theme + color = @theme[token[:type]] || @text_color + + # Calculate x position for this token + token_x_offset = @font_style.string_width(@value[f, visible_start - f].to_s) if visible_start > f + + rt.primitives << @font_style.label( + x: token_x_offset || 0, + y: @padding, + text: visible_text, + **color + ) + end + + char_index = token_end + break if char_index >= l + end + end + end +end diff --git a/lib/text.rb b/lib/text.rb index 6182c3f..131d8c2 100644 --- a/lib/text.rb +++ b/lib/text.rb @@ -200,13 +200,16 @@ def prepare_render_target rt.primitives << { x: left, y: 0, w: right - left, h: @font_height + @padding * 2 }.solid!(sc) end - # TEXT - f = find_index_at_x(@scroll_x) - l = find_index_at_x(@scroll_x + @content_w) + 2 - rt.primitives << @font_style.label(x: 0, y: @padding, text: @value[f, l - f], **@text_color) + render_text(rt) end draw_cursor(rt) end + + def render_text(rt) + f = find_index_at_x(@scroll_x) + l = find_index_at_x(@scroll_x + @content_w) + 2 + rt.primitives << @font_style.label(x: 0, y: @padding, text: @value[f, l - f], **@text_color) + end end end diff --git a/test b/test index 2764587..03cbde5 100755 --- a/test +++ b/test @@ -1,2 +1,19 @@ cd .. -SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy ./dragonruby dr-input --test tests/tests.rb --no-tick + +# Use provided test file or default to all tests +TEST_FILE=${1:-tests/tests.rb} + +# If a specific test file is provided, ensure it has the .rb extension and is in tests/ directory +if [ "$1" ]; then + # If the file doesn't start with tests/, add it + if [[ "$TEST_FILE" != tests/* ]]; then + TEST_FILE="tests/$TEST_FILE" + fi + + # If the file doesn't end with .rb, add it + if [[ "$TEST_FILE" != *.rb ]]; then + TEST_FILE="$TEST_FILE.rb" + fi +fi + +SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy ./dragonruby dr-input --test "$TEST_FILE" --no-tick diff --git a/tests/test_console_autocomplete.rb b/tests/test_console_autocomplete.rb new file mode 100644 index 0000000..28e02af --- /dev/null +++ b/tests/test_console_autocomplete.rb @@ -0,0 +1,71 @@ +require_relative 'test_helpers' + +# Mock GTK::Console for testing +class MockConsole + attr_accessor :visible, :log_offset + attr_reader :autocompleting, :autocomplete_prefix, :autocomplete_selection_end + + def initialize + @visible = true + @log_offset = 0 + @command_history = [] + @command_history_index = -1 + end + + def mouse_wheel_scroll(args); end + def console_toggle_key_down?(args); false; end + def slide_progress; 1.0; end + + # Override autocomplete_items to return mock data + def autocomplete_items(prefix) + %w[inputs outputs state passes_remaining].select { |m| m.start_with?(prefix) } + end +end + +# Prepend after class definition so MockConsole methods still override +MockConsole.prepend(Input::Console) + +def test_console_autocomplete_after_dot_tab(args, assert) + console = MockConsole.new + + # Simulate typing "$args." + console.prompt.value = "$args." + console.prompt.selection_start = 6 + console.prompt.selection_end = 6 + + # Simulate pressing TAB + console.autocomplete + + # After tab, we should be autocompleting + autocompleting = console.instance_variable_get(:@autocompleting) + assert.true! autocompleting, "Should be autocompleting" + + # The menu should have items + menu = console.instance_variable_get(:@autocomplete_menu) + items = menu.items + assert.false! items.empty?, "Menu should have items, got #{items.inspect}, menu=#{menu.inspect}" + + # The prompt value should be updated to show the first autocomplete option + value = console.prompt.value.to_s + assert.true! value.start_with?("$args."), "Value should start with '$args.': got #{value.inspect}" + assert.true! value.length > 6, "Value should be longer than '$args.': got #{value.inspect}" + assert.equal! value, "$args.inputs", "First autocomplete option should be 'inputs'" + + # The selection should highlight the autocompleted part + sel_start = console.prompt.selection_start + sel_end = console.prompt.selection_end + assert.equal! sel_start, 6, "Selection start should be at position 6 (after dot)" + assert.true! sel_end > 6, "Selection end should be after position 6" + assert.equal! sel_end, value.length, "Selection end should be at end of value" + + # The autocomplete prefix should be empty (nothing typed after the dot yet) + prefix = console.instance_variable_get(:@autocomplete_prefix) + assert.equal! prefix, "", "Autocomplete prefix should be empty" + + # shift_lock should be enabled to maintain selection + assert.true! console.prompt.shift_lock, "shift_lock should be enabled" + + # @value_changed should be false (reset after autocomplete_next) + value_changed = console.prompt.instance_variable_get(:@value_changed) + assert.false! value_changed, "@value_changed should be false after autocomplete" +end diff --git a/tests/test_ruby_text.rb b/tests/test_ruby_text.rb new file mode 100644 index 0000000..5777668 --- /dev/null +++ b/tests/test_ruby_text.rb @@ -0,0 +1,37 @@ +require 'tests/test_helpers.rb' + +def test_ruby_text_initialization(args, assert) + input = Input::RubyText.new(value: 'def foo; end') + assert.true! input.is_a?(Input::Text), 'RubyText should be a subclass of Text' + assert.equal! input.value.to_s, 'def foo; end' +end + +def test_ruby_text_with_custom_theme(args, assert) + custom_theme = { + keyword: { r: 255, g: 0, b: 0 } + } + input = Input::RubyText.new(value: 'def foo; end', theme: custom_theme) + assert.equal! input.instance_variable_get(:@theme)[:keyword], { r: 255, g: 0, b: 0, vertical_alignment_enum: 0 } +end + +def test_ruby_text_with_default_theme(args, assert) + input = Input::RubyText.new(value: 'def foo; end') + theme = input.instance_variable_get(:@theme) + assert.equal! theme[:keyword], { r: 197, g: 134, b: 192, vertical_alignment_enum: 0 } + assert.equal! theme[:string], { r: 152, g: 195, b: 121, vertical_alignment_enum: 0 } +end + +def test_ruby_text_parses_and_exposes_tokens(args, assert) + input = Input::RubyText.new(value: 'def foo; end', x: 0, y: 0, w: 200, h: 30) + + # First render - should parse + input.tick + tokens = input.tokens + assert.true! tokens.is_a?(Array), 'Should have tokens' + assert.true! tokens.length > 0, 'Should have parsed tokens' + + # Second render with same value - should use cache (value_changed is false) + input.tick + tokens2 = input.tokens + assert.equal! tokens.object_id, tokens2.object_id, 'Should reuse cached tokens when value unchanged' +end diff --git a/tests/test_value_changed.rb b/tests/test_value_changed.rb new file mode 100644 index 0000000..f0645e9 --- /dev/null +++ b/tests/test_value_changed.rb @@ -0,0 +1,25 @@ +require 'tests/test_helpers.rb' + +def test_value_changed_on_insert(args, assert) + input = build_text_input('hello') + + # tick resets @value_changed to false + input.tick + assert.false! input.value_changed?, 'value_changed should be false after tick' + + # insert sets @value_changed to true + input.insert('world') + assert.true! input.value_changed?, 'value_changed should be true after insert' +end + +def test_value_changed_on_value_setter(args, assert) + input = build_text_input('hello') + + # tick resets @value_changed to false + input.tick + assert.false! input.value_changed?, 'value_changed should be false after tick' + + # value= sets @value_changed to true + input.value = 'goodbye' + assert.true! input.value_changed?, 'value_changed should be true after value=' +end diff --git a/tests/tests.rb b/tests/tests.rb index cb2763e..d75586f 100644 --- a/tests/tests.rb +++ b/tests/tests.rb @@ -7,4 +7,7 @@ require_relative 'test_font_size_calculation' require_relative 'test_menu' require_relative 'test_delete_back' -require_relative 'test_bug_fixes' \ No newline at end of file +require_relative 'test_bug_fixes' +require_relative 'test_value_changed' +require_relative 'test_ruby_text' +require_relative 'test_console_autocomplete' \ No newline at end of file diff --git a/update_parser b/update_parser new file mode 100755 index 0000000..ba18543 --- /dev/null +++ b/update_parser @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e + +PARSER_DIR="../dr-parser-rb" +TARGET_DIR="./lib" + +echo "Building latest parser in $PARSER_DIR..." +cd "$PARSER_DIR" +./release + +echo "Copying parser.rb to $TARGET_DIR..." +cd - +cp "$PARSER_DIR/parser.rb" "$TARGET_DIR/parser.rb" + +echo "Parser updated successfully!"