terminal-layout 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +2 -0
- data/.travis.yml +14 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +60 -0
- data/README.md +7 -0
- data/block-flow.rb +361 -0
- data/lib/ansi_string.rb +315 -0
- data/lib/terminal_layout.rb +527 -0
- data/spec/ansi_string_spec.rb +499 -0
- data/spec/spec_helper.rb +102 -0
- data/spec/terminal_layout_spec.rb +745 -0
- data/terminal-layout.gemspec +28 -0
- data/test-1.rb +90 -0
- metadata +158 -0
@@ -0,0 +1,527 @@
|
|
1
|
+
require 'pry'
|
2
|
+
require 'ansi_string'
|
3
|
+
require 'ostruct'
|
4
|
+
|
5
|
+
module TerminalLayout
|
6
|
+
Dimension = Struct.new(:width, :height)
|
7
|
+
Position = Struct.new(:x, :y)
|
8
|
+
|
9
|
+
module EventEmitter
|
10
|
+
def _callbacks
|
11
|
+
@_callbacks ||= Hash.new { |h, k| h[k] = [] }
|
12
|
+
end
|
13
|
+
|
14
|
+
def on(type, *args, &blk)
|
15
|
+
_callbacks[type] << blk
|
16
|
+
self
|
17
|
+
end
|
18
|
+
|
19
|
+
def unsubscribe
|
20
|
+
_callbacks.clear
|
21
|
+
end
|
22
|
+
|
23
|
+
def emit(type, *args)
|
24
|
+
_callbacks[type].each do |blk|
|
25
|
+
blk.call(*args)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class RenderObject
|
31
|
+
include EventEmitter
|
32
|
+
|
33
|
+
attr_accessor :box, :style, :children, :content, :parent
|
34
|
+
|
35
|
+
def initialize(box, parent:, content:nil, style:{x:nil, y:nil}, renderer:nil)
|
36
|
+
@box = box
|
37
|
+
@content = ANSIString.new(content)
|
38
|
+
@children = []
|
39
|
+
@parent = parent
|
40
|
+
@renderer = renderer
|
41
|
+
@style = style
|
42
|
+
style[:x] || style[:x] = 0
|
43
|
+
style[:y] || style[:y] = 0
|
44
|
+
|
45
|
+
@box.update_computed(style)
|
46
|
+
end
|
47
|
+
|
48
|
+
def offset
|
49
|
+
offset_x = self.x
|
50
|
+
offset_y = self.y
|
51
|
+
_parent = @parent
|
52
|
+
loop do
|
53
|
+
break unless _parent
|
54
|
+
offset_x += _parent.x
|
55
|
+
offset_y += _parent.y
|
56
|
+
_parent = _parent.parent
|
57
|
+
end
|
58
|
+
Position.new(offset_x, offset_y)
|
59
|
+
end
|
60
|
+
|
61
|
+
def starting_x_for_current_y
|
62
|
+
children.map do |child|
|
63
|
+
next unless child.float == :left || child.display == :inline
|
64
|
+
next unless child.y && child.y <= @current_y && (child.y + child.height - 1) >= @current_y
|
65
|
+
|
66
|
+
[child.x + child.width, x].max
|
67
|
+
end.compact.max || 0
|
68
|
+
end
|
69
|
+
|
70
|
+
def ending_x_for_current_y
|
71
|
+
children.map do |child|
|
72
|
+
next unless child.float == :right
|
73
|
+
next unless child.y && child.y <= @current_y && (child.y + child.height - 1) >= @current_y
|
74
|
+
|
75
|
+
[child.x, width].min
|
76
|
+
end.compact.min || self.width || @box.width
|
77
|
+
end
|
78
|
+
|
79
|
+
%w(width height display x y float).each do |method|
|
80
|
+
define_method(method){ style[method.to_sym] }
|
81
|
+
|
82
|
+
define_method("#{method}=") do |value|
|
83
|
+
style[method.to_sym] = value
|
84
|
+
@box.computed[method] = value
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def position
|
89
|
+
Position.new(x, y)
|
90
|
+
end
|
91
|
+
|
92
|
+
def size
|
93
|
+
Dimension.new(width, height)
|
94
|
+
end
|
95
|
+
|
96
|
+
def width
|
97
|
+
style[:width]
|
98
|
+
end
|
99
|
+
|
100
|
+
def height
|
101
|
+
style[:height]
|
102
|
+
end
|
103
|
+
|
104
|
+
def inspect
|
105
|
+
to_s
|
106
|
+
end
|
107
|
+
|
108
|
+
def to_str
|
109
|
+
to_s
|
110
|
+
end
|
111
|
+
|
112
|
+
def to_s
|
113
|
+
"<#{self.class.name} position=(#{x},#{y}) dimensions=#{width}x#{height} content=#{content}/>"
|
114
|
+
end
|
115
|
+
|
116
|
+
def render
|
117
|
+
# Rather than worry about a 2-dimensional space we're going to cheat
|
118
|
+
# and convert everything to a single point.
|
119
|
+
result = height.times.map { |n| (" " * width) }.join
|
120
|
+
result = ANSIString.new(result)
|
121
|
+
|
122
|
+
if content && content.length > 0
|
123
|
+
result[0...content.length] = content.dup.to_s
|
124
|
+
end
|
125
|
+
|
126
|
+
children.each do |child|
|
127
|
+
rendered_content = child.render
|
128
|
+
|
129
|
+
# Find the single point where this child's content should be placed.
|
130
|
+
# (child.y * width): make sure we take into account the row we're on
|
131
|
+
# plus (child.y): make sure take into account the number of newlines
|
132
|
+
x = child.x + (child.y * width)
|
133
|
+
result[x...(x+rendered_content.length)] = rendered_content
|
134
|
+
end
|
135
|
+
|
136
|
+
result
|
137
|
+
end
|
138
|
+
|
139
|
+
def layout
|
140
|
+
self.children = []
|
141
|
+
@current_x = 0
|
142
|
+
@current_y = 0
|
143
|
+
if @box.display == :block && @box.content.length > 0
|
144
|
+
ending_x = ending_x_for_current_y
|
145
|
+
available_width = ending_x - @current_x
|
146
|
+
new_parent = Box.new(content: nil, style: @box.style.dup.merge(width: available_width))
|
147
|
+
inline_box = Box.new(content: @box.content, style: {display: :inline})
|
148
|
+
new_parent.children = [inline_box].concat @box.children
|
149
|
+
children2crawl = [new_parent]
|
150
|
+
else
|
151
|
+
children2crawl = @box.children
|
152
|
+
end
|
153
|
+
|
154
|
+
children2crawl.each do |cbox|
|
155
|
+
if cbox.display == :float
|
156
|
+
next if cbox.width.to_i == 0
|
157
|
+
|
158
|
+
render_object = layout_float cbox
|
159
|
+
cbox.height = render_object.height
|
160
|
+
|
161
|
+
next if cbox.height.to_i == 0
|
162
|
+
|
163
|
+
self.children << render_object
|
164
|
+
elsif cbox.display == :block
|
165
|
+
if children.last && children.last.display == :inline && @current_x != 0
|
166
|
+
@current_x = 0
|
167
|
+
@current_y += 1
|
168
|
+
end
|
169
|
+
|
170
|
+
@current_x = starting_x_for_current_y
|
171
|
+
available_width = ending_x_for_current_y - @current_x
|
172
|
+
|
173
|
+
if cbox.width && cbox.width > available_width
|
174
|
+
@current_y += 1
|
175
|
+
@current_x = starting_x_for_current_y
|
176
|
+
available_width = ending_x_for_current_y - @current_x
|
177
|
+
end
|
178
|
+
|
179
|
+
render_object = render_object_for(cbox, content:nil, style: {width: (cbox.width || available_width)})
|
180
|
+
render_object.layout
|
181
|
+
render_object.x = @current_x
|
182
|
+
render_object.y = @current_y
|
183
|
+
|
184
|
+
if cbox.height
|
185
|
+
render_object.height = cbox.height
|
186
|
+
end
|
187
|
+
|
188
|
+
next if [nil, 0].include?(render_object.width) || [nil, 0].include?(render_object.height)
|
189
|
+
|
190
|
+
@current_x = 0
|
191
|
+
@current_y += [render_object.height, 1].max
|
192
|
+
|
193
|
+
self.children << render_object
|
194
|
+
elsif cbox.display == :inline
|
195
|
+
@current_x = starting_x_for_current_y if @current_x == 0
|
196
|
+
available_width = ending_x_for_current_y - @current_x
|
197
|
+
|
198
|
+
content_i = 0
|
199
|
+
content = ""
|
200
|
+
|
201
|
+
loop do
|
202
|
+
partial_content = cbox.content[content_i...(content_i + available_width)]
|
203
|
+
chars_needed = partial_content.length
|
204
|
+
self.children << render_object_for(cbox, content:partial_content, style: {display: :inline, x:@current_x, y: @current_y, width:chars_needed, height:1})
|
205
|
+
|
206
|
+
content_i += chars_needed
|
207
|
+
|
208
|
+
if chars_needed >= available_width
|
209
|
+
@current_y += 1
|
210
|
+
@current_x = starting_x_for_current_y
|
211
|
+
available_width = ending_x_for_current_y - @current_x
|
212
|
+
elsif chars_needed == 0
|
213
|
+
break
|
214
|
+
else
|
215
|
+
@current_x += chars_needed
|
216
|
+
end
|
217
|
+
|
218
|
+
break if content_i >= cbox.content.length
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
if !height
|
224
|
+
if children.length >= 2
|
225
|
+
last_child = children.max{ |child| child.y }
|
226
|
+
self.height = last_child.y + last_child.height
|
227
|
+
elsif children.length == 1
|
228
|
+
self.height = self.children.first.height
|
229
|
+
else
|
230
|
+
self.height = @box.height || 0
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
self.children
|
235
|
+
end
|
236
|
+
|
237
|
+
def layout_float(fbox)
|
238
|
+
# only allow the float to be as wide as its parent
|
239
|
+
# - first check is the box itself, was it assigned a width?
|
240
|
+
if @box.width && fbox.width > width
|
241
|
+
fbox.width = width
|
242
|
+
end
|
243
|
+
|
244
|
+
if fbox.float == :left
|
245
|
+
# if we cannot fit on this line, go to the next
|
246
|
+
if @current_x + fbox.width > width
|
247
|
+
@current_x = 0
|
248
|
+
@current_y += 1
|
249
|
+
end
|
250
|
+
|
251
|
+
fbox.x = @current_x
|
252
|
+
fbox.y = @current_y
|
253
|
+
|
254
|
+
render_object = render_object_for(fbox, content: fbox.content, style: {height: fbox.height})
|
255
|
+
render_object.layout
|
256
|
+
|
257
|
+
@current_x += fbox.width
|
258
|
+
return render_object
|
259
|
+
elsif fbox.float == :right
|
260
|
+
# loop in case there are left floats on the left as we move down rows
|
261
|
+
loop do
|
262
|
+
starting_x = starting_x_for_current_y
|
263
|
+
available_width = ending_x_for_current_y - starting_x
|
264
|
+
|
265
|
+
# if we cannot fit on this line, go to the next
|
266
|
+
width_needed = fbox.width
|
267
|
+
if width_needed > available_width
|
268
|
+
@current_x = 0
|
269
|
+
@current_y += 1
|
270
|
+
else
|
271
|
+
break
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
@current_x = ending_x_for_current_y - fbox.width
|
276
|
+
fbox.x = @current_x
|
277
|
+
fbox.y = @current_y
|
278
|
+
|
279
|
+
render_object = render_object_for(fbox, content: fbox.content, style: {height: fbox.height})
|
280
|
+
render_object.layout
|
281
|
+
|
282
|
+
# reset X back to what it should be
|
283
|
+
@current_x = starting_x_for_current_y
|
284
|
+
return render_object
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
def render_object_for(cbox, content:nil, style:{})
|
289
|
+
case cbox.display
|
290
|
+
when :block
|
291
|
+
BlockRenderObject.new(cbox, parent: self, content: content, style: {width:@box.width}.merge(style), renderer:@renderer)
|
292
|
+
when :inline
|
293
|
+
InlineRenderObject.new(cbox, parent: self, content: content, style: style, renderer:@renderer)
|
294
|
+
when :float
|
295
|
+
FloatRenderObject.new(cbox, parent: self, content: content, style: {x: @current_x, y: @current_y, float: cbox.float}.merge(style), renderer:@renderer)
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
class RenderTree < RenderObject
|
301
|
+
end
|
302
|
+
|
303
|
+
class BlockRenderObject < RenderObject
|
304
|
+
def initialize(*args)
|
305
|
+
super
|
306
|
+
style.has_key?(:display) || style[:display] = :block
|
307
|
+
style.has_key?(:width) || style[:width] = @box.width
|
308
|
+
end
|
309
|
+
end
|
310
|
+
|
311
|
+
class FloatRenderObject < BlockRenderObject
|
312
|
+
end
|
313
|
+
|
314
|
+
class InlineRenderObject < RenderObject
|
315
|
+
end
|
316
|
+
|
317
|
+
class Box
|
318
|
+
include EventEmitter
|
319
|
+
|
320
|
+
attr_accessor :style, :children, :content, :computed
|
321
|
+
|
322
|
+
def initialize(style:{}, children:[], content:"")
|
323
|
+
@style = style
|
324
|
+
@children = children
|
325
|
+
@content = ANSIString.new(content)
|
326
|
+
@computed = {}
|
327
|
+
|
328
|
+
initialize_defaults
|
329
|
+
|
330
|
+
@children.each do |child|
|
331
|
+
child.on(:content_changed) do |*args|
|
332
|
+
emit :child_changed
|
333
|
+
end
|
334
|
+
|
335
|
+
child.on(:child_changed) do |*args|
|
336
|
+
emit :child_changed
|
337
|
+
end
|
338
|
+
|
339
|
+
child.on(:cursor_position_changed) do |*args|
|
340
|
+
emit :cursor_position_changed
|
341
|
+
end
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
%w(width height display x y float).each do |method|
|
346
|
+
define_method(method){ style[method.to_sym] }
|
347
|
+
define_method("#{method}="){ |value| style[method.to_sym] = value }
|
348
|
+
end
|
349
|
+
|
350
|
+
def content=(str)
|
351
|
+
old = @content
|
352
|
+
@content = ANSIString.new(str)
|
353
|
+
emit :content_changed, old, @content
|
354
|
+
end
|
355
|
+
|
356
|
+
def position
|
357
|
+
Position.new(x, y)
|
358
|
+
end
|
359
|
+
|
360
|
+
def size
|
361
|
+
Dimension.new(width, height)
|
362
|
+
end
|
363
|
+
|
364
|
+
def width
|
365
|
+
style[:width]
|
366
|
+
end
|
367
|
+
|
368
|
+
def height
|
369
|
+
style[:height]
|
370
|
+
end
|
371
|
+
|
372
|
+
def inspect
|
373
|
+
to_s
|
374
|
+
end
|
375
|
+
|
376
|
+
def to_str
|
377
|
+
to_s
|
378
|
+
end
|
379
|
+
|
380
|
+
def to_s
|
381
|
+
"<Box##{object_id} position=(#{x},#{y}) dimensions=#{width}x#{height} display=#{display.inspect} content=#{content}/>"
|
382
|
+
end
|
383
|
+
|
384
|
+
def update_computed(style)
|
385
|
+
@computed.merge!(style)
|
386
|
+
end
|
387
|
+
|
388
|
+
private
|
389
|
+
|
390
|
+
def initialize_defaults
|
391
|
+
style.has_key?(:display) || style[:display] = :block
|
392
|
+
end
|
393
|
+
end
|
394
|
+
|
395
|
+
|
396
|
+
class InputBox < Box
|
397
|
+
attr_accessor :cursor_position
|
398
|
+
|
399
|
+
def initialize(*args)
|
400
|
+
super
|
401
|
+
@cursor_offset_x = 0
|
402
|
+
@cursor_position = OpenStruct.new(x: 0, y: 0)
|
403
|
+
end
|
404
|
+
|
405
|
+
def content=(str)
|
406
|
+
old = @content
|
407
|
+
@content = ANSIString.new(str)
|
408
|
+
emit :content_changed, old, @content
|
409
|
+
end
|
410
|
+
|
411
|
+
def position=(position)
|
412
|
+
@cursor_offset_x = position
|
413
|
+
@cursor_position.x = @cursor_offset_x + @computed[:x]
|
414
|
+
emit :cursor_position_changed, nil, @cursor_position.x
|
415
|
+
end
|
416
|
+
|
417
|
+
def update_computed(style)
|
418
|
+
@computed.merge!(style)
|
419
|
+
@cursor_position.x = style[:x] + @cursor_offset_x
|
420
|
+
@cursor_position.y = style[:y]
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
require 'terminfo'
|
425
|
+
require 'termios'
|
426
|
+
require 'highline/system_extensions'
|
427
|
+
class TerminalRenderer
|
428
|
+
include HighLine::SystemExtensions
|
429
|
+
include EventEmitter
|
430
|
+
|
431
|
+
attr_reader :term_info
|
432
|
+
|
433
|
+
def initialize(output: $stdout)
|
434
|
+
@output = output
|
435
|
+
@term_info = TermInfo.new ENV["TERM"], @output
|
436
|
+
@x, @y = 0, 0
|
437
|
+
end
|
438
|
+
|
439
|
+
def render_cursor(input_box)
|
440
|
+
move_up_n_rows @y
|
441
|
+
move_to_beginning_of_row
|
442
|
+
|
443
|
+
cursor_position = input_box.cursor_position
|
444
|
+
cursor_x = cursor_position.x
|
445
|
+
cursor_y = cursor_position.y
|
446
|
+
|
447
|
+
# TODO: make this work when lines wrap
|
448
|
+
if cursor_x < 0
|
449
|
+
cursor_x = terminal_width
|
450
|
+
cursor_y -= 1
|
451
|
+
elsif cursor_x >= terminal_width
|
452
|
+
end
|
453
|
+
|
454
|
+
move_right_n_characters cursor_position.x
|
455
|
+
move_down_n_rows cursor_position.y
|
456
|
+
|
457
|
+
@y = input_box.cursor_position.y
|
458
|
+
end
|
459
|
+
|
460
|
+
def render(object)
|
461
|
+
dumb_render(object)
|
462
|
+
end
|
463
|
+
|
464
|
+
def reset
|
465
|
+
@y = 0
|
466
|
+
end
|
467
|
+
|
468
|
+
def dumb_render(object)
|
469
|
+
@output.print @term_info.control_string "civis"
|
470
|
+
move_up_n_rows @y
|
471
|
+
move_to_beginning_of_row
|
472
|
+
|
473
|
+
loop do
|
474
|
+
break unless object.parent
|
475
|
+
object = object.parent
|
476
|
+
end
|
477
|
+
|
478
|
+
object_width = object.width
|
479
|
+
clear_screen_down
|
480
|
+
|
481
|
+
rendered_content = object.render
|
482
|
+
printable_content = rendered_content.sub(/\s*\Z/m, '')
|
483
|
+
|
484
|
+
printable_content.lines.each do |line|
|
485
|
+
move_to_beginning_of_row
|
486
|
+
@output.puts line
|
487
|
+
end
|
488
|
+
move_to_beginning_of_row
|
489
|
+
|
490
|
+
# calculate lines drawn so we know where we are
|
491
|
+
lines_drawn = (printable_content.length / object_width.to_f).ceil
|
492
|
+
@y = lines_drawn
|
493
|
+
|
494
|
+
input_box = find_input_box(object.box)
|
495
|
+
|
496
|
+
render_cursor(input_box)
|
497
|
+
|
498
|
+
@output.print @term_info.control_string "cnorm"
|
499
|
+
end
|
500
|
+
|
501
|
+
def find_input_box(dom_node)
|
502
|
+
dom_node.children.detect do |child|
|
503
|
+
child.is_a?(InputBox) || find_input_box(child)
|
504
|
+
end
|
505
|
+
end
|
506
|
+
|
507
|
+
def clear_to_beginning_of_line ; term_info.control "el1" ; end
|
508
|
+
def clear_screen ; term_info.control "clear" ; end
|
509
|
+
def clear_screen_down ; term_info.control "ed" ; end
|
510
|
+
def move_to_beginning_of_row ; move_to_column 0 ; end
|
511
|
+
def move_left ; move_left_n_characters 1 ; end
|
512
|
+
def move_left_n_characters(n) ; n.times { term_info.control "cub1" } ; end
|
513
|
+
def move_right_n_characters(n) ; n.times { term_info.control "cuf1" } ; end
|
514
|
+
def move_to_column_and_row(column, row) ; term_info.control "cup", column, row ; end
|
515
|
+
def move_to_column(n) ; term_info.control "hpa", n ; end
|
516
|
+
def move_up_n_rows(n) ; n.times { term_info.control "cuu1" } ; end
|
517
|
+
def move_down_n_rows(n) ; n.times { term_info.control "cud1" } ; end
|
518
|
+
|
519
|
+
def terminal_width
|
520
|
+
terminal_size[0]
|
521
|
+
end
|
522
|
+
|
523
|
+
def terminal_height
|
524
|
+
terminal_size[1]
|
525
|
+
end
|
526
|
+
end
|
527
|
+
end
|