Class: Clacky::UI2::ScreenBuffer

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/ui2/screen_buffer.rb

Overview

ScreenBuffer manages terminal screen state and provides low-level rendering primitives

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeScreenBuffer

Returns a new instance of ScreenBuffer.



13
14
15
16
17
18
19
20
21
22
23
# File 'lib/clacky/ui2/screen_buffer.rb', line 13

def initialize
  @width = TTY::Screen.width
  @height = TTY::Screen.height
  @buffer = []
  @last_input_time = nil
  @rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input

  # Keep stdin in UTF-8 mode so getc returns complete multi-byte characters (e.g. CJK).
  # Switching to BINARY would cause getc to return one byte at a time, breaking Chinese input.
  $stdin.set_encoding('UTF-8')
end

Instance Attribute Details

#heightObject (readonly)

Returns the value of attribute height.



11
12
13
# File 'lib/clacky/ui2/screen_buffer.rb', line 11

def height
  @height
end

#widthObject (readonly)

Returns the value of attribute width.



11
12
13
# File 'lib/clacky/ui2/screen_buffer.rb', line 11

def width
  @width
end

Instance Method Details

#clear_lineObject

Clear current line



51
52
53
# File 'lib/clacky/ui2/screen_buffer.rb', line 51

def clear_line
  print "\e[2K"
end

#clear_screen(mode: :preserve) ⇒ Object

Clear screen with different modes:

:preserve - clear visible screen, scrollback history preserved (default)
:current  - cursor to top-left and erase to end, no new scrollback produced
:reset    - clear visible screen AND scrollback history (full reset)

Parameters:

  • mode (Symbol) (defaults to: :preserve)

    Clear mode (:preserve, :current, :reset)



37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/clacky/ui2/screen_buffer.rb', line 37

def clear_screen(mode: :preserve)
  case mode
  when :reset
    print "\e[3J"    # erase scrollback buffer
    print "\e[H\e[J" # cursor to top-left, erase to end of screen
  when :current
    print "\e[H\e[J" # cursor to top-left, erase to end of screen
  else # :preserve
    print "\e[2J\e[H"    # erase visible screen, scrollback preserved
  end
  move_cursor(0, 0)
end

#clear_to_eolObject

Clear from cursor to end of line



56
57
58
# File 'lib/clacky/ui2/screen_buffer.rb', line 56

def clear_to_eol
  print "\e[K"
end

#disable_alt_screenObject

Disable alternative screen buffer



86
87
88
# File 'lib/clacky/ui2/screen_buffer.rb', line 86

def disable_alt_screen
  print "\e[?1049l"
end

#disable_raw_modeObject

Disable raw mode



127
128
129
# File 'lib/clacky/ui2/screen_buffer.rb', line 127

def disable_raw_mode
  $stdin.cooked!
end

#enable_alt_screenObject

Enable alternative screen buffer (like vim/less)



81
82
83
# File 'lib/clacky/ui2/screen_buffer.rb', line 81

def enable_alt_screen
  print "\e[?1049h"
end

#enable_raw_modeObject

Enable raw mode (disable line buffering)



122
123
124
# File 'lib/clacky/ui2/screen_buffer.rb', line 122

def enable_raw_mode
  $stdin.raw!
end

#flushObject

Flush output



254
255
256
# File 'lib/clacky/ui2/screen_buffer.rb', line 254

def flush
  $stdout.flush
end

#hide_cursorObject

Hide cursor



61
62
63
# File 'lib/clacky/ui2/screen_buffer.rb', line 61

def hide_cursor
  print "\e[?25l"
end

#move_cursor(row, col) ⇒ Object

Move cursor to specific position (0-indexed)

Parameters:

  • row (Integer)

    Row position

  • col (Integer)

    Column position



28
29
30
# File 'lib/clacky/ui2/screen_buffer.rb', line 28

def move_cursor(row, col)
  print "\e[#{row + 1};#{col + 1}H"
end

#read_char(timeout: nil) ⇒ String?

Read a single character without echo

Parameters:

  • timeout (Float) (defaults to: nil)

    Timeout in seconds (nil for blocking)

Returns:

  • (String, nil)

    Character or nil if timeout



134
135
136
137
138
139
140
# File 'lib/clacky/ui2/screen_buffer.rb', line 134

def read_char(timeout: nil)
  if timeout
    return nil unless IO.select([$stdin], nil, nil, timeout)
  end

  $stdin.getc
end

#read_key(timeout: nil) ⇒ Symbol, ...

Read a key including special keys (arrows, etc.)

Parameters:

  • timeout (Float) (defaults to: nil)

    Timeout in seconds

Returns:

  • (Symbol, String, Hash, nil)

    Key symbol, character, or { type: :rapid_input, text: String }



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/clacky/ui2/screen_buffer.rb', line 145

def read_key(timeout: nil)
  current_time = Time.now.to_f
  is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
  @last_input_time = current_time

  char = read_char(timeout: timeout)
  return nil unless char

  # Convert raw BINARY bytes to valid UTF-8. Invalid/undefined bytes are dropped
  # rather than raising ArgumentError (which would crash the input loop).
  char = safe_to_utf8(char) if char.is_a?(String)

  # Handle escape sequences for special keys
  if char == "\e"
    # Non-blocking read for escape sequence
    char2 = read_char(timeout: 0.01)
    return :escape unless char2

    if char2 == "["
      char3 = read_char(timeout: 0.01)
      case char3
      when "A" then return :up_arrow
      when "B" then return :down_arrow
      when "C" then return :right_arrow
      when "D" then return :left_arrow
      when "H" then return :home
      when "F" then return :end
      when "Z" then return :shift_tab
      when "3"
        char4 = read_char(timeout: 0.01)
        return :delete if char4 == "~"
      end
    end
  end

  # Check if there are more characters available (for rapid input detection)
  has_more_input = IO.select([$stdin], nil, nil, 0)

  # If this is rapid input or there are more characters available
  if is_rapid_input || has_more_input
    buffer = char.to_s.dup

    # Keep reading available characters
    loop_count = 0
    empty_checks = 0

    loop do
      # Check if there's data available immediately
      has_data = IO.select([$stdin], nil, nil, 0)

      if has_data
        next_char = $stdin.getc rescue nil
        break unless next_char

        next_char = safe_to_utf8(next_char)
        buffer << next_char
        loop_count += 1
        empty_checks = 0  # Reset empty check counter
      else
        # No immediate data, but wait a bit to see if more is coming
        # This handles the case where paste data arrives in chunks
        empty_checks += 1
        if empty_checks == 1
          # First empty check - wait 10ms for more data
          sleep 0.01
        else
          # Second empty check - really no more data
          break
        end
      end
    end

    # If we buffered multiple characters or newlines, treat as rapid input (paste)
    if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
      # Ensure the accumulated buffer is valid UTF-8 before regex operations
      buffer = safe_to_utf8(buffer)
      # Remove any trailing \r or \n from rapid input buffer
      cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
      return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
    end

    # Single character, continue to normal handling
    char = buffer[0] if buffer.length == 1
  end

  # Handle control characters
  case char
  when "\r" then :enter
  when "\n" then :newline  # Shift+Enter sends \n
  when "\u007F", "\b" then :backspace
  when "\u0001" then :ctrl_a
  when "\u0002" then :ctrl_b
  when "\u0003" then :ctrl_c
  when "\u0004" then :ctrl_d
  when "\u0005" then :ctrl_e
  when "\u0006" then :ctrl_f
  when "\u000B" then :ctrl_k
  when "\u000C" then :ctrl_l
  when "\u000F" then :ctrl_o
  when "\u0012" then :ctrl_r
  when "\u0015" then :ctrl_u
  when "\u0016" then :ctrl_v
  when "\u0017" then :ctrl_w
  when "\t"     then :tab
  else char
  end
end

#reset_scroll_regionObject

Reset scroll region to full screen



99
100
101
# File 'lib/clacky/ui2/screen_buffer.rb', line 99

def reset_scroll_region
  print "\e[r"
end

#restore_cursorObject

Restore cursor position



76
77
78
# File 'lib/clacky/ui2/screen_buffer.rb', line 76

def restore_cursor
  print "\e[u"
end

#save_cursorObject

Save cursor position



71
72
73
# File 'lib/clacky/ui2/screen_buffer.rb', line 71

def save_cursor
  print "\e[s"
end

#scroll_down(n = 1) ⇒ Object

Scroll the scroll region down by n lines

Parameters:

  • n (Integer) (defaults to: 1)

    Number of lines to scroll



111
112
113
# File 'lib/clacky/ui2/screen_buffer.rb', line 111

def scroll_down(n = 1)
  print "\e[#{n}T"
end

#scroll_up(n = 1) ⇒ Object

Scroll the scroll region up by n lines

Parameters:

  • n (Integer) (defaults to: 1)

    Number of lines to scroll



105
106
107
# File 'lib/clacky/ui2/screen_buffer.rb', line 105

def scroll_up(n = 1)
  print "\e[#{n}S"
end

#set_scroll_region(top, bottom) ⇒ Object

Set scroll region (DECSTBM - DEC Set Top and Bottom Margins) Content written in this region will scroll, content outside will stay fixed

Parameters:

  • top (Integer)

    Top row (1-indexed)

  • bottom (Integer)

    Bottom row (1-indexed)



94
95
96
# File 'lib/clacky/ui2/screen_buffer.rb', line 94

def set_scroll_region(top, bottom)
  print "\e[#{top};#{bottom}r"
end

#show_cursorObject

Show cursor



66
67
68
# File 'lib/clacky/ui2/screen_buffer.rb', line 66

def show_cursor
  print "\e[?25h"
end

#update_dimensionsObject

Get current screen dimensions



116
117
118
119
# File 'lib/clacky/ui2/screen_buffer.rb', line 116

def update_dimensions
  @width = TTY::Screen.width
  @height = TTY::Screen.height
end